Skip to content

Commit a206aeb

Browse files
authored
Merge feature/add-files: Add file upload support and improved message rendering
Add file upload support and improved message rendering
2 parents c5a7de9 + 04907ae commit a206aeb

File tree

8 files changed

+242
-28
lines changed

8 files changed

+242
-28
lines changed
Lines changed: 167 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
package com.example;
2+
import javafx.collections.FXCollections;
3+
import javafx.collections.ObservableList;
24
import javafx.event.ActionEvent;
35
import javafx.fxml.FXML;
46
import javafx.geometry.Insets;
57
import javafx.scene.control.*;
8+
import javafx.scene.control.Button;
9+
import javafx.scene.control.Label;
10+
import javafx.scene.control.TextField;
11+
import javafx.scene.image.Image;
12+
import javafx.scene.image.ImageView;
613
import javafx.scene.layout.HBox;
14+
import javafx.stage.FileChooser;
15+
16+
import java.awt.*;
17+
import java.io.File;
18+
import java.net.URI;
719

820
/**
921
* Controller layer: mediates between the view (FXML) and the model.
@@ -12,61 +24,164 @@ public class HelloController {
1224

1325
private final HelloModel model = new HelloModel(new NtfyConnectionImpl());
1426
public ListView<NtfyMessageDto> messageView;
27+
private boolean lastActionWasFile = false;
1528

1629
@FXML
1730
private Label messageLabel;
1831

1932
@FXML
2033
private TextField messageField;
2134

35+
@FXML
36+
private void attachFile() {
37+
FileChooser chooser = new FileChooser();
38+
chooser.getExtensionFilters().add(
39+
new FileChooser.ExtensionFilter("Images", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.txt")
40+
);
41+
42+
File file = chooser.showOpenDialog(messageField.getScene().getWindow());
43+
if (file != null) {
44+
lastActionWasFile = true;
45+
model.sendFile(file);
46+
}
47+
}
48+
2249
@FXML
2350
private void initialize() {
51+
2452
if (messageLabel != null) {
2553
messageLabel.setText(model.getGreeting());
2654
}
55+
2756
messageView.setItems(model.getMessages());
2857

58+
// quickfix :D prevent listview cell selection which forces a re-render and
59+
// causes image messages to flicker or disappear when clicking or scrolling.
60+
messageView.setSelectionModel(new NoSelectionModel<>());
61+
62+
2963
messageView.setCellFactory(list -> new ListCell<>() {
30-
private final Label messageLabel = new Label();
31-
private final HBox bubble = new HBox(messageLabel);
64+
65+
private final Label textLabel = new Label();
66+
private final HBox textBubble = new HBox(textLabel);
67+
3268
{
33-
bubble.setPadding(new Insets(5, 10, 5, 10));
34-
bubble.setMaxWidth(200);
35-
messageLabel.setWrapText(true);
36-
bubble.getStyleClass().add("chat-bubble");
69+
textBubble.setPadding(new Insets(5, 10, 5, 10));
70+
textBubble.setMaxWidth(200);
71+
textLabel.setWrapText(true);
72+
textBubble.getStyleClass().add("chat-bubble");
3773
}
3874

3975
@Override
4076
protected void updateItem(NtfyMessageDto item, boolean empty) {
4177
super.updateItem(item, empty);
78+
4279
if (empty || item == null) {
80+
setText(null);
4381
setGraphic(null);
44-
} else {
45-
// Format tid + text
46-
java.time.LocalTime time = java.time.Instant.ofEpochSecond(item.time())
47-
.atZone(java.time.ZoneId.systemDefault())
48-
.toLocalTime();
49-
String formattedTime = time.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
50-
51-
messageLabel.setText(formattedTime + "\n" + item.message());
52-
setGraphic(bubble);
82+
return;
83+
}
84+
85+
if (item.attachment() != null &&
86+
item.attachment().type() != null &&
87+
item.attachment().type().startsWith("image") &&
88+
item.attachment().url() != null) {
89+
90+
try {
91+
Image image = new Image(item.attachment().url(), 200, 0, true, true);
92+
ImageView imageView = new ImageView(image);
93+
imageView.setPreserveRatio(true);
94+
95+
HBox imageBubble = new HBox(imageView);
96+
imageBubble.setPadding(new Insets(5, 10, 5, 10));
97+
imageBubble.setMaxWidth(200);
98+
imageBubble.getStyleClass().add("chat-bubble");
99+
100+
setText(null);
101+
setGraphic(imageBubble);
102+
} catch (Exception e) {
103+
Label err = new Label("[Image failed to load]");
104+
HBox bubble = new HBox(err);
105+
bubble.setPadding(new Insets(5, 10, 5, 10));
106+
bubble.getStyleClass().add("chat-bubble");
107+
setGraphic(bubble);
108+
}
109+
return;
110+
}
111+
112+
if (item.attachment() != null && item.attachment().url() != null) {
113+
114+
String fileName = item.attachment().name();
115+
String fileUrl = item.attachment().url();
116+
117+
Label icon = new Label("📄");
118+
icon.setStyle("-fx-font-size: 20px;");
119+
120+
Label fileLabel = new Label(fileName);
121+
fileLabel.setStyle("-fx-text-fill: white; -fx-font-size: 14px;");
122+
123+
Button openBtn = new Button("Open file");
124+
openBtn.setOnAction(e -> {
125+
System.out.println("Opening: " + fileUrl);
126+
new Thread(() -> {
127+
try {
128+
URI uri = new URI(fileUrl);
129+
Desktop.getDesktop().browse(uri);
130+
} catch (Exception ex) {
131+
ex.printStackTrace();
132+
}
133+
}).start();
134+
});
135+
136+
137+
HBox fileBox = new HBox(10, icon, fileLabel, openBtn);
138+
fileBox.setPadding(new Insets(5, 10, 5, 10));
139+
fileBox.setMaxWidth(200);
140+
fileBox.getStyleClass().add("chat-bubble");
141+
142+
setText(null);
143+
setGraphic(fileBox);
144+
return;
53145
}
146+
147+
148+
java.time.LocalTime time = java.time.Instant.ofEpochSecond(item.time())
149+
.atZone(java.time.ZoneId.systemDefault())
150+
.toLocalTime();
151+
String formattedTime = time.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
152+
153+
Label msgLabel = new Label(formattedTime + "\n" + item.message());
154+
msgLabel.setWrapText(true);
155+
msgLabel.setMaxWidth(250);
156+
157+
HBox msgBubble = new HBox(msgLabel);
158+
msgBubble.setPadding(new Insets(5, 10, 5, 10));
159+
msgBubble.setMaxWidth(300);
160+
msgBubble.getStyleClass().add("chat-bubble");
161+
162+
setText(null);
163+
setGraphic(msgBubble);
54164
}
165+
55166
});
56167

57168
model.messageToSendProperty().bind(messageField.textProperty());
58-
59169
}
60170

171+
61172
public void sendMessage(ActionEvent actionEvent) {
62173
String message = messageField.getText();
63-
if(message == null || message.isBlank()){
174+
if(!lastActionWasFile && (message == null || message.isBlank())){
64175
showTemporaryAlert("You must write something before sending!");
65176
return;
66177
}
67178

68-
model.sendMessage();
179+
if (message != null && !message.isBlank()) {
180+
model.sendMessage();
181+
}
182+
69183
messageField.clear();
184+
lastActionWasFile = false;
70185
}
71186

72187
private void showTemporaryAlert(String alertMessage) {
@@ -83,4 +198,38 @@ private void showTemporaryAlert(String alertMessage) {
83198
} catch (InterruptedException e) {}
84199
}).start();
85200
}
201+
202+
private static class NoSelectionModel<T> extends MultipleSelectionModel<T> {
203+
204+
/*
205+
Quickfix to prevent the ListView from selecting cells when the user clicks on them.
206+
Otherwise it triggers full re render of the listcells when which causes message bubbles
207+
or images to flicker or dissapears on click or scroll
208+
*/
209+
210+
@Override
211+
public ObservableList<Integer> getSelectedIndices() {
212+
return FXCollections.emptyObservableList();
213+
}
214+
215+
@Override
216+
public ObservableList<T> getSelectedItems() {
217+
return FXCollections.emptyObservableList();
218+
}
219+
220+
@Override public void selectIndices(int index, int... indices) { }
221+
@Override public void selectAll() { }
222+
@Override public void clearAndSelect(int index) { }
223+
@Override public void select(int index) { }
224+
@Override public void select(T obj) { }
225+
@Override public void clearSelection(int index) { }
226+
@Override public void clearSelection() { }
227+
@Override public boolean isSelected(int index) { return false; }
228+
@Override public boolean isEmpty() { return true; }
229+
@Override public void selectPrevious() { }
230+
@Override public void selectNext() { }
231+
@Override public void selectFirst() { }
232+
@Override public void selectLast() { }
233+
}
86234
}
235+

src/main/java/com/example/HelloModel.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import javafx.collections.FXCollections;
66
import javafx.collections.ObservableList;
77

8+
import java.io.File;
9+
810

911
/**
1012
* Model layer: encapsulates application data and business logic.
@@ -52,4 +54,8 @@ public void sendMessage() {
5254
public void receiveMessage() {
5355
connection.receive(m->Platform.runLater(()->messages.add(m)));
5456
}
57+
58+
public void sendFile(File file) {
59+
connection.sendFile(file);
60+
}
5561
}

src/main/java/com/example/NtfyConnection.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
public interface NtfyConnection {
66
public boolean send(String message);
7-
7+
boolean sendFile(java.io.File file);
88
public void receive(Consumer<NtfyMessageDto> messageHandler);
99
}

src/main/java/com/example/NtfyConnectionImpl.java

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.example;
22
import io.github.cdimascio.dotenv.Dotenv;
33
import tools.jackson.databind.ObjectMapper;
4+
5+
import java.io.File;
46
import java.io.IOException;
57
import java.net.URI;
68
import java.net.http.HttpClient;
@@ -28,7 +30,7 @@ public NtfyConnectionImpl(String hostName){
2830
public boolean send(String message) {
2931
HttpRequest httpRequest = HttpRequest.newBuilder()
3032
.POST(HttpRequest.BodyPublishers.ofString(message))
31-
.uri(URI.create(hostName + "/mytopic"))
33+
.uri(URI.create(hostName + "/adam"))
3234
.build();
3335
try {
3436
// TODO: handle long blocking send requests to not freeze the JavaFX thread
@@ -46,17 +48,53 @@ public boolean send(String message) {
4648

4749
@Override
4850
public void receive(Consumer<NtfyMessageDto> messageHandler) {
51+
String startId = "8TuugOLkvDz1"; // just to make the app wont load 10000000 messages
4952
HttpRequest httpRequest = HttpRequest.newBuilder()
5053
.GET()
51-
.uri(URI.create(hostName + "/mytopic/json?since=wBuD2KGEaAe0"))
54+
.uri(URI.create(hostName + "/adam/json?since=3hPbr2dcIUiU"))
5255
.build();
5356

57+
5458
http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
59+
5560
.thenAccept(response -> response.body()
56-
.map(s->
57-
mapper.readValue(s, NtfyMessageDto.class))
58-
.filter(message -> message.event().equals("message"))
59-
.peek(System.out::println)
60-
.forEach(messageHandler));
61+
.peek(s -> System.out.println(s))
62+
.map(s -> {
63+
try {
64+
return mapper.readValue(s, NtfyMessageDto.class);
65+
} catch (Exception e) {
66+
System.out.println("JSON parse fail: " + s);
67+
return null;
68+
}
69+
})
70+
.filter(msg -> msg != null)
71+
.filter(msg -> "message".equals(msg.event()))
72+
.forEach(messageHandler)
73+
);
74+
}
75+
76+
@Override
77+
public boolean sendFile(File file){
78+
try {
79+
String mime = java.nio.file.Files.probeContentType(file.toPath());
80+
81+
if(mime == null) mime = "application/octet-stream";
82+
83+
long size = file.length();
84+
85+
HttpRequest request = HttpRequest.newBuilder()
86+
.uri(URI.create(hostName + "/adam"))
87+
.header("Filename", file.getName())
88+
.header("Content-Type", mime)
89+
.PUT(HttpRequest.BodyPublishers.ofFile(file.toPath()))
90+
.build();
91+
92+
http.sendAsync(request, HttpResponse.BodyHandlers.discarding());
93+
return true;
94+
95+
} catch (Exception e) {
96+
e.printStackTrace();
97+
return false;
98+
}
6199
}
62100
}

src/main/java/com/example/NtfyMessageDto.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,22 @@
33
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
44

55
@JsonIgnoreProperties(ignoreUnknown = true)
6-
public record NtfyMessageDto(String id, long time, String event, String topic, String message) {}
6+
public record NtfyMessageDto(
7+
String id,
8+
long time,
9+
long expires,
10+
String event,
11+
String topic,
12+
String message,
13+
Attachment attachment
14+
) {
15+
@JsonIgnoreProperties(ignoreUnknown = true)
16+
public record Attachment(
17+
String name,
18+
String type,
19+
String url,
20+
long expires,
21+
long size
22+
){}
23+
24+
}

src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
requires java.net.http;
66
requires tools.jackson.databind;
77
requires javafx.graphics;
8+
requires java.desktop;
89

910
opens com.example to javafx.fxml;
1011
exports com.example;

src/main/resources/com/example/hello-view.fxml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
</VBox.margin>
1717

1818
<TextField fx:id="messageField" prefHeight="40" promptText="Write something here.." HBox.hgrow="ALWAYS" />
19+
<Button text="📎" onAction="#attachFile" prefHeight="40" prefWidth="40"/>
20+
1921
<Button onAction="#sendMessage" prefHeight="40" prefWidth="80" text="Send" />
2022
</HBox>
2123

src/main/resources/com/example/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
-fx-font-size: 12px;
2828
-fx-font-weight: bold;
2929
-fx-padding: 8 24 8 24;
30-
-fx-cursor: hand;/
30+
-fx-cursor: hand;
3131
-fx-border-color: transparent;
3232
-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.25), 8, 0, 0, 3);
3333
-fx-transition: all 0.3s ease;

0 commit comments

Comments
 (0)