package envoy.client.ui.controller; import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import javafx.animation.RotateTransition; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.control.Alert.AlertType; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.GridPane; import javafx.scene.paint.Color; import javafx.stage.FileChooser; import javafx.util.Duration; import envoy.client.data.*; import envoy.client.data.audio.AudioRecorder; import envoy.client.event.MessageCreationEvent; import envoy.client.net.Client; import envoy.client.net.WriteProxy; import envoy.client.ui.IconUtil; import envoy.client.ui.Restorable; import envoy.client.ui.SceneContext; import envoy.client.ui.listcell.ContactListCellFactory; import envoy.client.ui.listcell.MessageControl; import envoy.client.ui.listcell.MessageListCellFactory; import envoy.data.*; import envoy.data.Attachment.AttachmentType; import envoy.event.*; import envoy.event.contact.ContactOperation; import envoy.exception.EnvoyException; import envoy.util.EnvoyLog; /** * Project: envoy-client
* File: ChatSceneController.java
* Created: 26.03.2020
* * @author Kai S. K. Engelbart * @since Envoy Client v0.1-beta */ public final class ChatScene implements Restorable { @FXML private GridPane scene; @FXML private Label contactLabel; @FXML private ListView messageList; @FXML private ListView chatList; @FXML private Button postButton; @FXML private Button voiceButton; @FXML private Button attachmentButton; @FXML private Button settingsButton; @FXML private Button rotateButton; @FXML private TextArea messageTextArea; @FXML private Label remainingChars; @FXML private Label infoLabel; @FXML private MenuItem deleteContactMenuItem; @FXML private ImageView attachmentView; private LocalDB localDB; private Client client; private WriteProxy writeProxy; private SceneContext sceneContext; private Chat currentChat; private AudioRecorder recorder; private boolean recording; private Attachment pendingAttachment; private boolean postingPermanentlyDisabled; private static final Settings settings = Settings.getInstance(); private static final EventBus eventBus = EventBus.getInstance(); private static final Logger logger = EnvoyLog.getLogger(ChatScene.class); private static final Image DEFAULT_ATTACHMENT_VIEW_IMAGE = IconUtil.loadIconThemeSensitive("attachment_present", 20); private static final int MAX_MESSAGE_LENGTH = 255; private static final int DEFAULT_ICON_SIZE = 16; /** * Initializes the appearance of certain visual components. * * @since Envoy Client v0.1-beta */ @FXML private void initialize() { // Initialize message and user rendering messageList.setCellFactory(MessageListCellFactory::new); chatList.setCellFactory(ContactListCellFactory::new); settingsButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("settings", DEFAULT_ICON_SIZE))); voiceButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE))); attachmentButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("attachment", DEFAULT_ICON_SIZE))); attachmentView.setImage(DEFAULT_ATTACHMENT_VIEW_IMAGE); rotateButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("rotate", (int) (DEFAULT_ICON_SIZE * 1.5)))); // Listen to received messages eventBus.register(MessageCreationEvent.class, e -> { final var message = e.get(); localDB.getChat(message instanceof GroupMessage ? message.getRecipientID() : message.getSenderID()).ifPresent(chat -> { chat.insert(message); if (chat.equals(currentChat)) { try { currentChat.read(writeProxy); } catch (final IOException e1) { logger.log(Level.WARNING, "Could not read current chat: ", e1); } Platform.runLater(() -> { messageList.refresh(); scrollToMessageListEnd(); }); } else chat.incrementUnreadAmount(); // Moving chat with most recent unreadMessages to the top Platform.runLater(() -> { chatList.getItems().remove(chat); chatList.getItems().add(0, chat); if (chat.equals(currentChat)) chatList.getSelectionModel().select(0); localDB.getChats().remove(chat); localDB.getChats().add(0, chat); }); }); }); // Listen to message status changes eventBus.register(MessageStatusChange.class, e -> localDB.getMessage(e.getID()).ifPresent(message -> { message.setStatus(e.get()); // Update UI if in current chat if (currentChat != null && message.getSenderID() == currentChat.getRecipient().getID()) Platform.runLater(messageList::refresh); })); eventBus.register(GroupMessageStatusChange.class, e -> localDB.getMessage(e.getID()).ifPresent(groupMessage -> { ((GroupMessage) groupMessage).getMemberStatuses().replace(e.getMemberID(), e.get()); // Update UI if in current chat if (currentChat != null && groupMessage.getRecipientID() == currentChat.getRecipient().getID()) Platform.runLater(messageList::refresh); })); // Listen to user status changes eventBus.register(UserStatusChange.class, e -> chatList.getItems() .stream() .filter(c -> c.getRecipient().getID() == e.getID()) .findAny() .map(Chat::getRecipient) .ifPresent(u -> { ((User) u).setStatus(e.get()); Platform.runLater(chatList::refresh); })); // Listen to contacts changes eventBus.register(ContactOperation.class, e -> { final var contact = e.get(); switch (e.getOperationType()) { case ADD: localDB.getUsers().put(contact.getName(), contact); Chat chat = contact instanceof User ? new Chat(contact) : new GroupChat(client.getSender(), contact); localDB.getChats().add(chat); Platform.runLater(() -> chatList.getItems().add(chat)); break; case REMOVE: localDB.getUsers().remove(contact.getName()); localDB.getChats().removeIf(c -> c.getRecipient().getID() == contact.getID()); Platform.runLater(() -> chatList.getItems().removeIf(c -> c.getRecipient().getID() == contact.getID())); break; } }); } /** * Initializes all necessary data via dependency injection- * * @param sceneContext the scene context used to load other scenes * @param localDB the local database form which chats and users are loaded * @param client the client used to request ID generators * @param writeProxy the write proxy used to send messages and other data to * the server * @since Envoy Client v0.1-beta */ public void initializeData(SceneContext sceneContext, LocalDB localDB, Client client, WriteProxy writeProxy) { this.sceneContext = sceneContext; this.localDB = localDB; this.client = client; this.writeProxy = writeProxy; chatList.setItems(FXCollections.observableList(localDB.getChats())); contactLabel.setText(localDB.getUser().getName()); MessageControl.setUser(localDB.getUser()); if (!client.isOnline()) updateInfoLabel("You are offline", "infoLabel-info"); recorder = new AudioRecorder(); } @Override public void onRestore() { updateRemainingCharsLabel(); } /** * Actions to perform when the list of contacts has been clicked. * * @since Envoy Client v0.1-beta */ @FXML private void chatListClicked() { final Contact user = chatList.getSelectionModel().getSelectedItem().getRecipient(); if (user != null && (currentChat == null || !user.equals(currentChat.getRecipient()))) { // LEON: JFC <===> JAVA FRIED CHICKEN <=/=> Java Foundation Classes // Load the chat currentChat = localDB.getChat(user.getID()).get(); messageList.setItems(FXCollections.observableList(currentChat.getMessages())); final var scrollIndex = messageList.getItems().size() - currentChat.getUnreadAmount() - 1; messageList.scrollTo(scrollIndex); logger.log(Level.FINEST, "Loading chat with " + user + " at index " + scrollIndex); deleteContactMenuItem.setText("Delete " + user.getName()); // Read the current chat try { currentChat.read(writeProxy); } catch (final IOException e) { logger.log(Level.WARNING, "Could not read current chat.", e); } // Discard the pending attachment if (recorder.isRecording()) { recorder.cancel(); recording = false; } pendingAttachment = null; updateAttachmentView(false); remainingChars.setVisible(true); remainingChars .setText(String.format("remaining chars: %d/%d", MAX_MESSAGE_LENGTH - messageTextArea.getText().length(), MAX_MESSAGE_LENGTH)); } messageTextArea.setDisable(currentChat == null || postingPermanentlyDisabled); voiceButton.setDisable(!recorder.isSupported()); attachmentButton.setDisable(false); chatList.refresh(); } /** * Actions to perform when the Settings Button has been clicked. * * @since Envoy Client v0.1-beta */ @FXML private void settingsButtonClicked() { sceneContext.load(SceneContext.SceneInfo.SETTINGS_SCENE); sceneContext.getController().initializeData(sceneContext); } /** * Actions to perform when the "Add Contact" - Button has been clicked. * * @since Envoy Client v0.1-beta */ @FXML private void addContactButtonClicked() { sceneContext.load(SceneContext.SceneInfo.CONTACT_SEARCH_SCENE); sceneContext.getController().initializeData(sceneContext, localDB); } @FXML private void voiceButtonClicked() { new Thread(() -> { try { if (!recording) { recording = true; Platform.runLater(() -> { voiceButton.setText("Recording"); voiceButton.setGraphic(new ImageView(IconUtil.loadIcon("microphone_recording", DEFAULT_ICON_SIZE))); }); recorder.start(); } else { pendingAttachment = new Attachment(recorder.finish(), AttachmentType.VOICE); recording = false; Platform.runLater(() -> { voiceButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("microphone", DEFAULT_ICON_SIZE))); voiceButton.setText(null); checkPostConditions(false); updateAttachmentView(true); }); } } catch (final EnvoyException e) { logger.log(Level.SEVERE, "Could not record audio: ", e); Platform.runLater(new Alert(AlertType.ERROR, "Could not record audio")::showAndWait); } }).start(); } @FXML private void attachmentButtonClicked() { // Display file chooser final var fileChooser = new FileChooser(); fileChooser.setTitle("Add Attachment"); fileChooser.setInitialDirectory(new File(System.getProperty("user.home"))); fileChooser.getExtensionFilters() .addAll(new FileChooser.ExtensionFilter("Pictures", "*.png", "*.jpg", "*.bmp", "*.gif"), new FileChooser.ExtensionFilter("Videos", "*.mp4"), new FileChooser.ExtensionFilter("All Files", "*.*")); final var file = fileChooser.showOpenDialog(sceneContext.getStage()); if (file != null) { // Check max file size if (file.length() > 16E6) { new Alert(AlertType.WARNING, "The selected file exceeds the size limit of 16MB!").showAndWait(); return; } // Get attachment type (default is document) AttachmentType type = AttachmentType.DOCUMENT; switch (fileChooser.getSelectedExtensionFilter().getDescription()) { case "Pictures": type = AttachmentType.PICTURE; break; case "Videos": type = AttachmentType.VIDEO; break; } // Create the pending attachment try { final var fileBytes = Files.readAllBytes(file.toPath()); pendingAttachment = new Attachment(fileBytes, type); // Setting the preview image as image of the attachmentView if (type == AttachmentType.PICTURE) attachmentView.setImage(new Image(new ByteArrayInputStream(fileBytes), DEFAULT_ICON_SIZE, DEFAULT_ICON_SIZE, true, true)); attachmentView.setVisible(true); } catch (final IOException e) { new Alert(AlertType.ERROR, "The selected file could not be loaded!").showAndWait(); } } } /** * Rotates every element in our application by 360° in at most 2.75s. * * @since Envoy Client v0.1-beta */ @FXML private void doABarrelRoll() { // contains all Node objects in ChatScene in alphabetical order final var rotatableNodes = new Node[] { attachmentButton, attachmentView, contactLabel, infoLabel, messageList, messageTextArea, postButton, remainingChars, rotateButton, scene, settingsButton, chatList, voiceButton }; final var random = new Random(); for (final var node : rotatableNodes) { // Defines at most four whole rotation in at most 4s final var rotateTransition = new RotateTransition(Duration.seconds(random.nextDouble() * 3 + 1), node); rotateTransition.setByAngle((random.nextInt(3) + 1) * 360); rotateTransition.play(); // This is needed as for some strange reason objects could stop before being // rotated back to 0° rotateTransition.setOnFinished(e -> node.setRotate(0)); } } /** * Checks the text length of the {@code messageTextArea}, adjusts the * {@code remainingChars} label and checks whether to send the message * automatically. * * @param e the key event that will be analyzed for a post request * @since Envoy Client v0.1-beta */ @FXML private void checkKeyCombination(KeyEvent e) { // Checks whether the text is too long messageTextUpdated(); // Automatic sending of messages via (ctrl +) enter checkPostConditions(e); } /** * @param e the keys that have been pressed * @since Envoy Client v0.1-beta */ @FXML private void checkPostConditions(KeyEvent e) { checkPostConditions(settings.isEnterToSend() && e.getCode() == KeyCode.ENTER || !settings.isEnterToSend() && e.getCode() == KeyCode.ENTER && e.isControlDown()); } private void checkPostConditions(boolean sendKeyPressed) { if (!postingPermanentlyDisabled) { if (!postButton.isDisabled() && sendKeyPressed) postMessage(); postButton.setDisable(messageTextArea.getText().isBlank() && pendingAttachment == null || currentChat == null); } else { final var noMoreMessaging = "Go online to send messages"; if (!infoLabel.getText().equals(noMoreMessaging)) // Informing the user that he is a f*cking moron and should use Envoy online // because he ran out of messageIDs to use updateInfoLabel(noMoreMessaging, "infoLabel-error"); } } /** * Actions to perform when the text was updated in the messageTextArea. * * @since Envoy Client v0.1-beta */ @FXML private void messageTextUpdated() { // Truncating messages that are too long and staying at the same position if (messageTextArea.getText().length() >= MAX_MESSAGE_LENGTH) { messageTextArea.setText(messageTextArea.getText().substring(0, MAX_MESSAGE_LENGTH)); messageTextArea.positionCaret(MAX_MESSAGE_LENGTH); messageTextArea.setScrollTop(Double.MAX_VALUE); } updateRemainingCharsLabel(); } /** * Sets the text and text color of the {@code remainingChars} label. * * @since Envoy Client v0.1-beta */ private void updateRemainingCharsLabel() { final int currentLength = messageTextArea.getText().length(); final int remainingLength = MAX_MESSAGE_LENGTH - currentLength; remainingChars.setText(String.format("remaining chars: %d/%d", remainingLength, MAX_MESSAGE_LENGTH)); remainingChars.setTextFill(Color.rgb(currentLength, remainingLength, 0, 1)); } /** * Sends a new {@link Message} or {@link GroupMessage} to the server based on * the text entered in the {@code messageTextArea} and the given attachment. * * @since Envoy Client v0.1-beta */ @FXML private void postMessage() { postingPermanentlyDisabled = !(client.isOnline() || localDB.getIDGenerator().hasNext()); if (postingPermanentlyDisabled) { postButton.setDisable(true); messageTextArea.setDisable(true); messageTextArea.clear(); updateInfoLabel("You need to go online to send more messages", "infoLabel-error"); return; } final var text = messageTextArea.getText().strip(); try { // Creating the message and its metadata final var builder = new MessageBuilder(localDB.getUser().getID(), currentChat.getRecipient().getID(), localDB.getIDGenerator()) .setText(text); // Setting an attachment, if present if (pendingAttachment != null) { builder.setAttachment(pendingAttachment); pendingAttachment = null; updateAttachmentView(false); } // Building the final message final var message = currentChat.getRecipient() instanceof Group ? builder.buildGroupMessage((Group) currentChat.getRecipient()) : builder.build(); // Send message writeProxy.writeMessage(message); // Add message to LocalDB and update UI currentChat.insert(message); // Moving currentChat to the top Platform.runLater(() -> { chatList.getItems().remove(currentChat); chatList.getItems().add(0, currentChat); chatList.getSelectionModel().select(0); localDB.getChats().remove(currentChat); localDB.getChats().add(0, currentChat); }); messageList.refresh(); scrollToMessageListEnd(); // Request a new ID generator if all IDs were used if (!localDB.getIDGenerator().hasNext() && client.isOnline()) client.requestIdGenerator(); } catch (final IOException e) { logger.log(Level.SEVERE, "Error while sending message: ", e); new Alert(AlertType.ERROR, "An error occured while sending the message!").showAndWait(); } // Clear text field and disable post button messageTextArea.setText(""); postButton.setDisable(true); updateRemainingCharsLabel(); } /** * Scrolls to the bottom of the {@code messageList}. * * @since Envoy Client v0.1-beta */ private void scrollToMessageListEnd() { messageList.scrollTo(messageList.getItems().size() - 1); } /** * Updates the {@code infoLabel}. * * @param text the text to use * @param infoLabelID the id the the {@code infoLabel} should have so that it * can be styled accordingly in CSS * @since Envoy Client v0.1-beta */ private void updateInfoLabel(String text, String infoLabelID) { infoLabel.setText(text); infoLabel.setId(infoLabelID); infoLabel.setVisible(true); } /** * Updates the {@code attachmentView} in terms of visibility.
* Additionally resets the shown image to * {@code DEFAULT_ATTACHMENT_VIEW_IMAGE} if another image is currently * present. * * @param visible whether the {@code attachmentView} should be displayed * @since Envoy Client v0.1-beta */ private void updateAttachmentView(boolean visible) { if (!attachmentView.getImage().equals(DEFAULT_ATTACHMENT_VIEW_IMAGE)) attachmentView.setImage(DEFAULT_ATTACHMENT_VIEW_IMAGE); attachmentView.setVisible(visible); } // Context menu actions @FXML private void deleteContact() { try {} catch (final NullPointerException e) {} } @FXML private void copyAndPostMessage() { final var messageText = messageTextArea.getText(); Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(messageText), null); postMessage(); messageTextArea.setText(messageText); updateRemainingCharsLabel(); postButton.setDisable(messageText.isBlank()); } }