package envoy.client.ui.controller; import java.awt.Toolkit; import java.awt.datatransfer.StringSelection; import java.io.*; import java.nio.file.Files; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.logging.*; import javafx.animation.RotateTransition; import javafx.application.Platform; import javafx.collections.ObservableList; import javafx.collections.transformation.FilteredList; import javafx.fxml.*; import javafx.scene.control.*; import javafx.scene.control.Alert.AlertType; import javafx.scene.image.*; import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.stage.FileChooser; import javafx.util.Duration; import envoy.client.data.*; import envoy.client.data.audio.AudioRecorder; import envoy.client.event.*; import envoy.client.net.*; import envoy.client.ui.*; import envoy.client.ui.chatscene.*; import envoy.client.ui.control.ChatControl; import envoy.client.ui.listcell.*; import envoy.client.util.*; import envoy.data.*; import envoy.data.Attachment.AttachmentType; import envoy.data.Message.MessageStatus; import envoy.event.*; import envoy.event.contact.ContactOperation; import envoy.exception.EnvoyException; import envoy.util.EnvoyLog; import dev.kske.eventbus.*; import dev.kske.eventbus.Event; /** * Controller for the chat scene. * * @author Kai S. K. Engelbart * @since Envoy Client v0.1-beta */ public final class ChatScene implements EventListener, 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 messageSearchButton; @FXML private Button newGroupButton; @FXML private Button newContactButton; @FXML private TextArea messageTextArea; @FXML private Label remainingChars; @FXML private Label infoLabel; @FXML private MenuItem deleteContactMenuItem; @FXML private ImageView attachmentView; @FXML private Label topBarContactLabel; @FXML private Label topBarStatusLabel; @FXML private ImageView clientProfilePic; @FXML private ImageView recipientProfilePic; @FXML private TextArea contactSearch; @FXML private VBox contactOperations; @FXML private TabPane tabPane; @FXML private Tab contactSearchTab; @FXML private Tab groupCreationTab; @FXML private HBox contactSpecificOnlineOperations; private Chat currentChat; private FilteredList chats; private boolean recording; private Attachment pendingAttachment; private boolean postingPermanentlyDisabled; private boolean isCustomAttachmentImage; private ChatSceneCommands commands; private final LocalDB localDB = context.getLocalDB(); private final Client client = context.getClient(); private final WriteProxy writeProxy = context.getWriteProxy(); private final SceneContext sceneContext = context.getSceneContext(); private final AudioRecorder recorder = new AudioRecorder(); private final Tooltip onlyIfOnlineTooltip = new Tooltip("You need to be online to do this"); private static Image DEFAULT_ATTACHMENT_VIEW_IMAGE = IconUtil.loadIconThemeSensitive("attachment_present", 20); 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 Context context = Context.getInstance(); 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() { eventBus.registerListener(this); commands = new ChatSceneCommands(messageList, this); // Initialize message and user rendering messageList.setCellFactory(MessageListCell::new); chatList.setCellFactory(new ListCellFactory<>(ChatControl::new)); // JavaFX provides an internal way of populating the context menu of a text // area. // We, however, need additional functionality. messageTextArea.setContextMenu(new TextInputContextMenu(messageTextArea, e -> checkKeyCombination(null))); // Set the icons of buttons and image views 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); messageSearchButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("search", DEFAULT_ICON_SIZE))); clientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); onlyIfOnlineTooltip.setShowDelay(Duration.millis(250)); final var clip = new Rectangle(); clip.setWidth(43); clip.setHeight(43); clip.setArcHeight(43); clip.setArcWidth(43); clientProfilePic.setClip(clip); chatList.setItems(chats = new FilteredList<>(localDB.getChats())); contactLabel.setText(localDB.getUser().getName()); Platform.runLater(() -> { final var online = client.isOnline(); // no check will be performed in case it has already been disabled - a negative // GroupCreationResult might have been returned if (!newGroupButton.isDisabled()) newGroupButton.setDisable(!online); newContactButton.setDisable(!online); if (online) try { Tooltip.uninstall(contactSpecificOnlineOperations, onlyIfOnlineTooltip); contactSearchTab.setContent(new FXMLLoader().load(getClass().getResourceAsStream("/fxml/ContactSearchTab.fxml"))); groupCreationTab.setContent(new FXMLLoader().load(getClass().getResourceAsStream("/fxml/GroupCreationTab.fxml"))); } catch (final IOException e) { logger.log(Level.SEVERE, "An error occurred when attempting to load tabs: ", e); } else { Tooltip.install(contactSpecificOnlineOperations, onlyIfOnlineTooltip); updateInfoLabel("You are offline", "info-label-warning"); } }); } @Event(eventType = BackEvent.class) private void onBackEvent() { tabPane.getSelectionModel().select(Tabs.CONTACT_LIST.ordinal()); } @Event(includeSubtypes = true) private void onMessage(Message message) { // The sender of the message is the recipient of the chat // Exceptions: this user is the sender (sync) or group message (group is // recipient) final var ownMessage = message.getSenderID() == localDB.getUser().getID(); final var recipientID = message instanceof GroupMessage || ownMessage ? message.getRecipientID() : message.getSenderID(); localDB.getChat(recipientID).ifPresent(chat -> { chat.insert(message); // Read current chat or increment unread amount if (chat.equals(currentChat)) { currentChat.read(writeProxy); Platform.runLater(this::scrollToMessageListEnd); } else if (!ownMessage && message.getStatus() != MessageStatus.READ) chat.incrementUnreadAmount(); // Move chat with most recent unread messages to the top Platform.runLater(() -> { chats.getSource().remove(chat); ((ObservableList) chats.getSource()).add(0, chat); if (chat.equals(currentChat)) chatList.getSelectionModel().select(0); }); }); } @Event private void onMessageStatusChange(MessageStatusChange evt) { // Update UI if in current chat and the current user was the sender of the // message if (currentChat != null) localDB.getMessage(evt.getID()) .filter(msg -> msg.getSenderID() == client.getSender().getID()) .ifPresent(msg -> Platform.runLater(messageList::refresh)); } @Event(eventType = UserStatusChange.class) private void onUserStatusChange() { Platform.runLater(chatList::refresh); } @Event private void onContactOperation(ContactOperation operation) { final var contact = operation.get(); switch (operation.getOperationType()) { case ADD: if (contact instanceof User) localDB.getUsers().put(contact.getName(), (User) contact); final var chat = contact instanceof User ? new Chat(contact) : new GroupChat(client.getSender(), contact); Platform.runLater(() -> ((ObservableList) chats.getSource()).add(0, chat)); break; case REMOVE: Platform.runLater(() -> chats.getSource().removeIf(c -> c.getRecipient().equals(contact))); break; } } @Event(eventType = NoAttachments.class) private void onNoAttachments() { Platform.runLater(() -> { attachmentButton.setDisable(true); voiceButton.setDisable(true); final var alert = new Alert(AlertType.ERROR); alert.setTitle("No attachments possible"); alert.setHeaderText("Your current server does not support attachments."); alert.setContentText("If this is unplanned, please contact your server administrator."); alert.showAndWait(); }); } @Event private void onGroupCreationResult(GroupCreationResult result) { Platform.runLater(() -> newGroupButton.setDisable(!result.get())); } @Event(eventType = ThemeChangeEvent.class) private void onThemeChange() { 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))); DEFAULT_ATTACHMENT_VIEW_IMAGE = IconUtil.loadIconThemeSensitive("attachment_present", 20); attachmentView.setImage(isCustomAttachmentImage ? attachmentView.getImage() : DEFAULT_ATTACHMENT_VIEW_IMAGE); messageSearchButton.setGraphic(new ImageView(IconUtil.loadIconThemeSensitive("search", DEFAULT_ICON_SIZE))); clientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); chatList.setCellFactory(new ListCellFactory<>(ChatControl::new)); messageList.setCellFactory(MessageListCell::new); // TODO: cache image if (currentChat.getRecipient() instanceof User) recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); else recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43)); } @Event(eventType = Logout.class, priority = 200) private void onLogout() { eventBus.removeListener(this); } @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() { if (chatList.getSelectionModel().isEmpty()) return; final var 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(currentChat.getMessages()); final var scrollIndex = messageList.getItems().size() - currentChat.getUnreadAmount(); messageList.scrollTo(scrollIndex); logger.log(Level.FINEST, "Loading chat with " + user + " at index " + scrollIndex); deleteContactMenuItem.setText("Delete " + user.getName()); // Read the current chat currentChat.read(writeProxy); // 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(); if (currentChat != null) { topBarContactLabel.setText(currentChat.getRecipient().getName()); if (currentChat.getRecipient() instanceof User) { final var status = ((User) currentChat.getRecipient()).getStatus().toString(); topBarStatusLabel.setText(status); topBarStatusLabel.getStyleClass().add(status.toLowerCase()); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); } else { topBarStatusLabel.setText(currentChat.getRecipient().getContacts().size() + " members"); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43)); } final var clip = new Rectangle(); clip.setWidth(43); clip.setHeight(43); clip.setArcHeight(43); clip.setArcWidth(43); recipientProfilePic.setClip(clip); messageSearchButton.setVisible(true); } } /** * 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); } /** * Actions to perform when the "Add Contact" - Button has been clicked. * * @since Envoy Client v0.1-beta */ @FXML private void addContactButtonClicked() { tabPane.getSelectionModel().select(Tabs.CONTACT_SEARCH.ordinal()); } @FXML private void groupCreationButtonClicked() { tabPane.getSelectionModel().select(Tabs.GROUP_CREATION.ordinal()); } @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(), "Voice_recording_" + DateTimeFormatter.ofPattern("yyyy_MM_dd-HH_mm_ss").format(LocalDateTime.now()) + "." + AudioRecorder.FILE_FORMAT, 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) var 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, file.getName(), type); checkPostConditions(false); // 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)); isCustomAttachmentImage = 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 {@code rotations}*360° in * {@code an}. * * @param rotations the amount of times the scene is rotated by 360° * @param animationTime the time in seconds that this animation lasts * @since Envoy Client v0.1-beta */ public void doABarrelRoll(int rotations, double animationTime) { // Limiting the rotations and duration rotations = Math.min(rotations, 100000); rotations = Math.max(rotations, 1); animationTime = Math.min(animationTime, 150); animationTime = Math.max(animationTime, 0.25); // contains all Node objects in ChatScene final var rotatableNodes = ReflectionUtil.getAllDeclaredNodeVariables(this); for (final var node : rotatableNodes) { // Sets the animation duration to {animationTime} final var rotateTransition = new RotateTransition(Duration.seconds(animationTime), node); // rotates every element {rotations} times rotateTransition.setByAngle(rotations * 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(); // Sending an IsTyping event if none has been sent for // IsTyping#millisecondsActive if (client.isOnline() && currentChat.getLastWritingEvent() + IsTyping.millisecondsActive <= System.currentTimeMillis()) { client.send(new IsTyping(getChatID(), currentChat.getRecipient().getID())); currentChat.lastWritingEventWasNow(); } // KeyPressed will be called before the char has been added to the text, hence // this is needed for the first char if (messageTextArea.getText().length() == 1 && e != null) checkPostConditions(e); // This is needed for the messageTA context menu else if (e == null) checkPostConditions(false); } /** * Returns the id that should be used to send things to the server: the id of * 'our' {@link User} if the recipient of that object is another User, else the * id of the {@link Group} 'our' user is sending to. * * @return an id that can be sent to the server * @since Envoy Client v0.2-beta */ private long getChatID() { return currentChat.getRecipient() instanceof User ? client.getSender().getID() : currentChat.getRecipient().getID(); } /** * @param e the keys that have been pressed * @since Envoy Client v0.1-beta */ @FXML private void checkPostConditions(KeyEvent e) { final var enterPressed = e.getCode() == KeyCode.ENTER; final var messagePosted = enterPressed ? settings.isEnterToSend() ? !e.isControlDown() : e.isControlDown() : false; if (messagePosted) { // Removing an inserted line break if added by pressing enter final var text = messageTextArea.getText(); final var textPosition = messageTextArea.getCaretPosition() - 1; if (!e.isControlDown() && !text.isEmpty() && text.charAt(textPosition) == '\n') messageTextArea.setText(new StringBuilder(text).deleteCharAt(textPosition).toString()); } // if control is pressed, the enter press is originally invalidated. Here it'll // be inserted again else if (enterPressed && e.isControlDown()) { var caretPosition = messageTextArea.getCaretPosition(); messageTextArea.setText(new StringBuilder(messageTextArea.getText()).insert(caretPosition, '\n').toString()); messageTextArea.positionCaret(++caretPosition); } checkPostConditions(messagePosted); } private void checkPostConditions(boolean postMessage) { if (!postingPermanentlyDisabled) { if (!postButton.isDisabled() && postMessage) 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, "info-label-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 var currentLength = messageTextArea.getText().length(); final var 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", "info-label-error"); return; } final var text = messageTextArea.getText().strip(); if (!commands.getChatSceneCommands().executeIfAnyPresent(text)) { // 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(() -> { chats.getSource().remove(currentChat); ((ObservableList) chats.getSource()).add(0, currentChat); chatList.getSelectionModel().select(0); localDB.getChats().remove(currentChat); localDB.getChats().add(0, currentChat); }); scrollToMessageListEnd(); // Request a new ID generator if all IDs were used if (!localDB.getIDGenerator().hasNext() && client.isOnline()) client.requestIDGenerator(); } // Clear text field and disable post button messageTextArea.setText(""); postButton.setDisable(true); updateRemainingCharsLabel(); isCustomAttachmentImage = false; } /** * 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); final var image = attachmentView.getImage(); final var messageAttachment = pendingAttachment; postMessage(); messageTextArea.setText(messageText); updateRemainingCharsLabel(); postButton.setDisable(messageText.isBlank()); attachmentView.setImage(image); if (attachmentView.getImage() != null) attachmentView.setVisible(true); pendingAttachment = messageAttachment; } /** * Clears the current message selection. * * @since Envoy Client v0.3-beta */ public void clearMessageSelection() { messageList.getSelectionModel().clearSelection(); } @FXML private void searchContacts() { chats.setPredicate(contactSearch.getText().isBlank() ? c -> true : c -> c.getRecipient().getName().toLowerCase().contains(contactSearch.getText().toLowerCase())); } }