From 80795a3fc2f144879ed1a29eb9053c649e9a010a Mon Sep 17 00:00:00 2001 From: delvh Date: Wed, 30 Sep 2020 20:50:58 +0200 Subject: [PATCH] Add Ability to Delete Messages Locally (#70) Merge branch 'develop' into f/delete-messages Additionally added system commands to copy, delete or save attachments of selected messages Reviewed-on: https://git.kske.dev/zdm/envoy/pulls/70 Reviewed-by: kske Reviewed-by: DieGurke --- .../src/main/java/envoy/client/data/Chat.java | 13 ++- .../main/java/envoy/client/data/LocalDB.java | 19 ++++ .../envoy/client/event/MessageDeletion.java | 20 ++++ .../client/ui/control/MessageControl.java | 106 ++++++++---------- .../ui/control/TextInputContextMenu.java | 1 + .../envoy/client/ui/controller/ChatScene.java | 33 +++++- .../java/envoy/client/util/MessageUtil.java | 105 +++++++++++++++++ .../envoy/server/data/PersistenceManager.java | 2 + 8 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 client/src/main/java/envoy/client/event/MessageDeletion.java create mode 100644 client/src/main/java/envoy/client/util/MessageUtil.java diff --git a/client/src/main/java/envoy/client/data/Chat.java b/client/src/main/java/envoy/client/data/Chat.java index 1f88098..beabf9c 100644 --- a/client/src/main/java/envoy/client/data/Chat.java +++ b/client/src/main/java/envoy/client/data/Chat.java @@ -74,7 +74,7 @@ public class Chat implements Serializable { public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Chat)) return false; - final Chat other = (Chat) obj; + final var other = (Chat) obj; return Objects.equals(recipient, other.recipient); } @@ -89,7 +89,7 @@ public class Chat implements Serializable { */ public void read(WriteProxy writeProxy) { for (int i = messages.size() - 1; i >= 0; --i) { - final Message m = messages.get(i); + final var m = messages.get(i); if (m.getSenderID() == recipient.getID()) if (m.getStatus() == MessageStatus.READ) break; else { m.setStatus(MessageStatus.READ); @@ -121,6 +121,15 @@ public class Chat implements Serializable { messages.add(0, message); } + /** + * Removes the message with the given ID. + * + * @param messageID the ID of the message to remove + * @return whether the message has been found and removed + * @since Envoy Client v0.3-beta + */ + public boolean remove(long messageID) { return messages.removeIf(m -> m.getID() == messageID); } + /** * Increments the amount of unread messages. * diff --git a/client/src/main/java/envoy/client/data/LocalDB.java b/client/src/main/java/envoy/client/data/LocalDB.java index b5c90ab..51fb2cc 100644 --- a/client/src/main/java/envoy/client/data/LocalDB.java +++ b/client/src/main/java/envoy/client/data/LocalDB.java @@ -7,6 +7,7 @@ import java.time.Instant; import java.util.*; import java.util.logging.*; +import javafx.application.Platform; import javafx.collections.*; import envoy.client.event.*; @@ -273,6 +274,24 @@ public final class LocalDB implements EventListener { cacheMap.clear(); } + /** + * Deletes the message with the given ID, if present. + * + * @param message the event that was + * @since Envoy Client v0.3-beta + */ + @Event + private void onMessageDeletion(MessageDeletion message) { + Platform.runLater(() -> { + + // We suppose that messages have unique IDs, hence the search can be stopped + // once a message was removed + final var messageID = message.get(); + for (final var chat : chats) + if (chat.remove(messageID)) break; + }); + } + /** * @return a {@code Map} of all users stored locally with their * user names as keys diff --git a/client/src/main/java/envoy/client/event/MessageDeletion.java b/client/src/main/java/envoy/client/event/MessageDeletion.java new file mode 100644 index 0000000..e3f5ada --- /dev/null +++ b/client/src/main/java/envoy/client/event/MessageDeletion.java @@ -0,0 +1,20 @@ +package envoy.client.event; + +import envoy.event.Event; + +/** + * Conveys the deletion of a message. + * + * @author Leon Hofmeister + * @since Envoy Common v0.3-beta + */ +public class MessageDeletion extends Event { + + private static final long serialVersionUID = 1L; + + /** + * @param messageID the ID of the deleted message + * @since Envoy Common v0.3-beta + */ + public MessageDeletion(long messageID) { super(messageID); } +} diff --git a/client/src/main/java/envoy/client/ui/control/MessageControl.java b/client/src/main/java/envoy/client/ui/control/MessageControl.java index 11f5681..862bb45 100644 --- a/client/src/main/java/envoy/client/ui/control/MessageControl.java +++ b/client/src/main/java/envoy/client/ui/control/MessageControl.java @@ -1,8 +1,6 @@ package envoy.client.ui.control; -import java.awt.Toolkit; -import java.awt.datatransfer.StringSelection; -import java.io.*; +import java.io.ByteArrayInputStream; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Map; @@ -12,11 +10,10 @@ import javafx.geometry.*; import javafx.scene.control.*; import javafx.scene.image.*; import javafx.scene.layout.*; -import javafx.stage.FileChooser; import envoy.client.data.*; -import envoy.client.ui.*; -import envoy.client.util.IconUtil; +import envoy.client.net.Client; +import envoy.client.util.*; import envoy.data.*; import envoy.data.Message.MessageStatus; import envoy.util.EnvoyLog; @@ -32,13 +29,13 @@ public final class MessageControl extends Label { private final boolean ownMessage; - private final LocalDB localDB = Context.getInstance().getLocalDB(); - private final SceneContext sceneContext = Context.getInstance().getSceneContext(); + private final LocalDB localDB = context.getLocalDB(); + private final Client client = context.getClient(); + private static final Context context = Context.getInstance(); private static final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss") .withZone(ZoneId.systemDefault()); private static final Map statusImages = IconUtil.loadByEnum(MessageStatus.class, 16); - private static final Settings settings = Settings.getInstance(); private static final Logger logger = EnvoyLog.getLogger(MessageControl.class); /** @@ -47,6 +44,8 @@ public final class MessageControl extends Label { * @since Envoy Client v0.1-beta */ public MessageControl(Message message) { + ownMessage = message.getSenderID() == localDB.getUser().getID(); + // Creating the underlying VBox and the dateLabel final var hbox = new HBox(); if (message.getSenderID() != localDB.getUser().getID() && message instanceof GroupMessage) { @@ -67,18 +66,42 @@ public final class MessageControl extends Label { final var vbox = new VBox(hbox); // Creating the actions for the MenuItems - final var contextMenu = new ContextMenu(); - final var copyMenuItem = new MenuItem("Copy"); - final var deleteMenuItem = new MenuItem("Delete"); - final var forwardMenuItem = new MenuItem("Forward"); - final var quoteMenuItem = new MenuItem("Quote"); - final var infoMenuItem = new MenuItem("Info"); - copyMenuItem.setOnAction(e -> copyMessage(message)); - deleteMenuItem.setOnAction(e -> deleteMessage(message)); - forwardMenuItem.setOnAction(e -> forwardMessage(message)); - quoteMenuItem.setOnAction(e -> quoteMessage(message)); + final var contextMenu = new ContextMenu(); + final var items = contextMenu.getItems(); + + // Copy message action + if (!message.getText().isEmpty()) { + final var copyMenuItem = new MenuItem("Copy Text"); + copyMenuItem.setOnAction(e -> MessageUtil.copyMessageText(message)); + items.add(copyMenuItem); + } + + // Delete message - if own message - action + if (ownMessage && client.isOnline()) { + final var deleteMenuItem = new MenuItem("Delete locally"); + deleteMenuItem.setOnAction(e -> MessageUtil.deleteMessage(message)); + items.add(deleteMenuItem); + } + + // As long as these types of messages are not implemented and no caches are + // defined for them, we only want them to appear when being online + if (client.isOnline()) { + + // Forward menu item + final var forwardMenuItem = new MenuItem("Forward"); + forwardMenuItem.setOnAction(e -> MessageUtil.forwardMessage(message)); + items.add(forwardMenuItem); + + // Quote menu item + final var quoteMenuItem = new MenuItem("Quote"); + quoteMenuItem.setOnAction(e -> MessageUtil.quoteMessage(message)); + items.add(quoteMenuItem); + } + + // Info actions + final var infoMenuItem = new MenuItem("Info"); infoMenuItem.setOnAction(e -> loadMessageInfoScene(message)); - contextMenu.getItems().addAll(copyMenuItem, deleteMenuItem, forwardMenuItem, quoteMenuItem, infoMenuItem); + items.add(infoMenuItem); // Handling message attachment display // TODO: Add missing attachment types @@ -97,8 +120,8 @@ public final class MessageControl extends Label { break; } final var saveAttachment = new MenuItem("Save attachment"); - saveAttachment.setOnAction(e -> saveAttachment(message)); - contextMenu.getItems().add(saveAttachment); + saveAttachment.setOnAction(e -> MessageUtil.saveAttachment(message)); + items.add(saveAttachment); } // Creating the textLabel final var textLabel = new Label(message.getText()); @@ -116,12 +139,8 @@ public final class MessageControl extends Label { hBoxBottom.getChildren().add(statusIcon); hBoxBottom.setAlignment(Pos.BOTTOM_RIGHT); getStyleClass().add("own-message"); - ownMessage = true; hbox.setAlignment(Pos.CENTER_RIGHT); - } else { - getStyleClass().add("received-message"); - ownMessage = false; - } + } else getStyleClass().add("received-message"); vbox.getChildren().add(hBoxBottom); // Adjusting height and weight of the cell to the corresponding ListView paddingProperty().setValue(new Insets(5, 20, 5, 20)); @@ -129,41 +148,8 @@ public final class MessageControl extends Label { setGraphic(vbox); } - // Context Menu actions - - private void copyMessage(Message message) { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(message.getText()), null); - } - - private void deleteMessage(Message message) { logger.log(Level.FINEST, "message deletion was requested for " + message); } - - private void forwardMessage(Message message) { logger.log(Level.FINEST, "message forwarding was requested for " + message); } - - private void quoteMessage(Message message) { logger.log(Level.FINEST, "message quotation was requested for " + message); } - private void loadMessageInfoScene(Message message) { logger.log(Level.FINEST, "message info scene was requested for " + message); } - private void saveAttachment(Message message) { - File file; - final var fileName = message.getAttachment().getName(); - final var downloadLocation = settings.getDownloadLocation(); - // Show save file dialog, if the user did not opt-out - if (!settings.isDownloadSavedWithoutAsking()) { - final var fileChooser = new FileChooser(); - fileChooser.setInitialFileName(fileName); - fileChooser.setInitialDirectory(downloadLocation); - file = fileChooser.showSaveDialog(sceneContext.getStage()); - } else file = new File(downloadLocation, fileName); - - // A file was selected - if (file != null) try (FileOutputStream fos = new FileOutputStream(file)) { - fos.write(message.getAttachment().getData()); - logger.log(Level.FINE, "Attachment of message was saved at " + file.getAbsolutePath()); - } catch (final IOException e) { - logger.log(Level.WARNING, "Could not save attachment of " + message + ": ", e); - } - } - /** * @return whether the message stored by this {@code MessageControl} has been * sent by this user of Envoy diff --git a/client/src/main/java/envoy/client/ui/control/TextInputContextMenu.java b/client/src/main/java/envoy/client/ui/control/TextInputContextMenu.java index 9925f7d..bafed83 100644 --- a/client/src/main/java/envoy/client/ui/control/TextInputContextMenu.java +++ b/client/src/main/java/envoy/client/ui/control/TextInputContextMenu.java @@ -82,6 +82,7 @@ public class TextInputContextMenu extends ContextMenu { copyMI.disableProperty().bind(control.selectedTextProperty().isEmpty()); deleteMI.disableProperty().bind(control.selectedTextProperty().isEmpty()); clearMI.disableProperty().bind(control.textProperty().isEmpty()); + selectAllMI.disableProperty().bind(control.textProperty().isEmpty()); setOnShowing(e -> pasteMI.setDisable(!Clipboard.getSystemClipboard().hasString())); selectAllMI.getProperties().put("refreshMenu", Boolean.TRUE); diff --git a/client/src/main/java/envoy/client/ui/controller/ChatScene.java b/client/src/main/java/envoy/client/ui/controller/ChatScene.java index 0aa705d..a4ef479 100644 --- a/client/src/main/java/envoy/client/ui/controller/ChatScene.java +++ b/client/src/main/java/envoy/client/ui/controller/ChatScene.java @@ -223,8 +223,8 @@ public final class ChatScene implements EventListener, Restorable { // 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 boolean ownMessage = message.getSenderID() == localDB.getUser().getID(); - final var recipientID = message instanceof GroupMessage || ownMessage ? message.getRecipientID() : message.getSenderID(); + 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); @@ -336,6 +336,24 @@ public final class ChatScene implements EventListener, Restorable { .setNumberOfArguments(0) .setDescription("Opens the settings screen") .build("settings"); + + // Copy text of selection initialization + builder.setAction(text -> { + final var selectedMessage = messageList.getSelectionModel().getSelectedItem(); + if (selectedMessage != null) MessageUtil.copyMessageText(selectedMessage); + }).setNumberOfArguments(0).setDescription("Copies the text of the currently selected message").build("cp-s"); + + // Delete selection initialization + builder.setAction(text -> { + final var selectedMessage = messageList.getSelectionModel().getSelectedItem(); + if (selectedMessage != null) MessageUtil.deleteMessage(selectedMessage); + }).setNumberOfArguments(0).setDescription("Deletes the currently selected message").build("del-s"); + + // Save attachment of selection initialization + builder.setAction(text -> { + final var selectedMessage = messageList.getSelectionModel().getSelectedItem(); + if (selectedMessage != null && selectedMessage.hasAttachment()) MessageUtil.saveAttachment(selectedMessage); + }).setNumberOfArguments(0).setDescription("Copies the text of the currently selected message").build("save-a-s"); } @Override @@ -387,7 +405,7 @@ public final class ChatScene implements EventListener, Restorable { if (currentChat != null) { topBarContactLabel.setText(currentChat.getRecipient().getName()); if (currentChat.getRecipient() instanceof User) { - final String status = ((User) currentChat.getRecipient()).getStatus().toString(); + final var status = ((User) currentChat.getRecipient()).getStatus().toString(); topBarStatusLabel.setText(status); topBarStatusLabel.getStyleClass().add(status.toLowerCase()); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); @@ -395,7 +413,7 @@ public final class ChatScene implements EventListener, Restorable { topBarStatusLabel.setText(currentChat.getRecipient().getContacts().size() + " members"); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43)); } - final Rectangle clip = new Rectangle(); + final var clip = new Rectangle(); clip.setWidth(43); clip.setHeight(43); clip.setArcHeight(43); @@ -753,6 +771,13 @@ public final class ChatScene implements EventListener, Restorable { 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 diff --git a/client/src/main/java/envoy/client/util/MessageUtil.java b/client/src/main/java/envoy/client/util/MessageUtil.java new file mode 100644 index 0000000..468e8dc --- /dev/null +++ b/client/src/main/java/envoy/client/util/MessageUtil.java @@ -0,0 +1,105 @@ +package envoy.client.util; + +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.io.*; +import java.util.logging.*; + +import javafx.stage.FileChooser; + +import envoy.client.data.*; +import envoy.client.event.MessageDeletion; +import envoy.client.ui.controller.ChatScene; +import envoy.data.Message; +import envoy.util.EnvoyLog; + +import dev.kske.eventbus.EventBus; + +/** + * Contains methods that are commonly used for {@link Message}s. + * + * @author Leon Hofmeister + * @since Envoy Client v0.3-beta + */ +public class MessageUtil { + + private MessageUtil() {} + + private static Logger logger = EnvoyLog.getLogger(MessageUtil.class); + + /** + * Copies the text of the given message to the System Clipboard. + * + * @param message the message whose text to copy + * @since Envoy Client v0.3-beta + */ + public static void copyMessageText(Message message) { + logger.log(Level.FINEST, "A copy of message text \"" + message.getText() + "\" was requested"); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(message.getText()), null); + } + + /** + * Deletes the given message. + * + * @param message the message to delete + * @since Envoy Client v0.3-beta + */ + public static void deleteMessage(Message message) { + final var messageDeletionEvent = new MessageDeletion(message.getID()); + final var controller = Context.getInstance().getSceneContext().getController(); + if (controller instanceof ChatScene) ((ChatScene) controller).clearMessageSelection(); + + // Removing the message locally + EventBus.getInstance().dispatch(messageDeletionEvent); + + logger.log(Level.FINEST, "message deletion was requested for " + message); + } + + /** + * Forwards the given message. + * Currently not implemented. + * + * @param message the message to forward + * @since Envoy Client v0.3-beta + */ + public static void forwardMessage(Message message) { logger.log(Level.FINEST, "Message forwarding was requested for " + message); } + + /** + * Quotes the given message. + * Currently not implemented. + * + * @param message the message to quote + * @since Envoy Client v0.3-beta + */ + public static void quoteMessage(Message message) { logger.log(Level.FINEST, "Message quotation was requested for " + message); } + + /** + * Saves the attachment of a message, if present. + * + * @param message the message whose attachment to save + * @throws IllegalStateException if no attachment is present in the message + * @since Envoy Client v0.3-beta + */ + public static void saveAttachment(Message message) { + if (!message.hasAttachment()) throw new IllegalArgumentException("Cannot save a non-existing attachment"); + File file; + final var fileName = message.getAttachment().getName(); + final var downloadLocation = Settings.getInstance().getDownloadLocation(); + + // Show save file dialog, if the user did not opt-out + if (!Settings.getInstance().isDownloadSavedWithoutAsking()) { + final var fileChooser = new FileChooser(); + fileChooser.setInitialFileName(fileName); + fileChooser.setInitialDirectory(downloadLocation); + file = fileChooser.showSaveDialog(Context.getInstance().getSceneContext().getStage()); + } else file = new File(downloadLocation, fileName); + + // A file was selected + if (file != null) try (var fos = new FileOutputStream(file)) { + fos.write(message.getAttachment().getData()); + logger.log(Level.FINE, "Attachment of message was saved at " + file.getAbsolutePath()); + } catch (final IOException e) { + logger.log(Level.WARNING, "Could not save attachment of " + message + ": ", e); + } + } +} diff --git a/server/src/main/java/envoy/server/data/PersistenceManager.java b/server/src/main/java/envoy/server/data/PersistenceManager.java index f601fe5..67fa4e9 100755 --- a/server/src/main/java/envoy/server/data/PersistenceManager.java +++ b/server/src/main/java/envoy/server/data/PersistenceManager.java @@ -9,6 +9,8 @@ import envoy.data.User.UserStatus; import envoy.server.net.ConnectionManager; /** + * Contains operations used for persistence. + * * @author Leon Hofmeister * @author Maximilian Käfer * @since Envoy Server Standalone v0.1-alpha