diff --git a/client/src/main/java/envoy/client/data/Chat.java b/client/src/main/java/envoy/client/data/Chat.java index 1f88098..75ca5a3 100644 --- a/client/src/main/java/envoy/client/data/Chat.java +++ b/client/src/main/java/envoy/client/data/Chat.java @@ -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 any message has been 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 0e0da55..f99913c 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.*; @@ -235,7 +236,7 @@ public final class LocalDB implements EventListener { @Event(priority = 150) private void onUserStatusChange(UserStatusChange evt) { - this.getChat(evt.getID()).map(Chat::getRecipient).map(User.class::cast).ifPresent(u -> u.setStatus(evt.get())); + getChat(evt.getID()).map(Chat::getRecipient).map(User.class::cast).ifPresent(u -> u.setStatus(evt.get())); } @Event(priority = 150) @@ -273,6 +274,24 @@ public final class LocalDB implements EventListener { cacheMap.clear(); } + /** + * Deletes the message with the given ID, if any is 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/ui/control/MessageControl.java b/client/src/main/java/envoy/client/ui/control/MessageControl.java index 11f5681..53aa962 100644 --- a/client/src/main/java/envoy/client/ui/control/MessageControl.java +++ b/client/src/main/java/envoy/client/ui/control/MessageControl.java @@ -15,12 +15,16 @@ import javafx.scene.layout.*; import javafx.stage.FileChooser; import envoy.client.data.*; -import envoy.client.ui.*; +import envoy.client.net.Client; +import envoy.client.ui.SceneContext; import envoy.client.util.IconUtil; import envoy.data.*; import envoy.data.Message.MessageStatus; +import envoy.event.MessageDeletion; import envoy.util.EnvoyLog; +import dev.kske.eventbus.EventBus; + /** * This class transforms a single {@link Message} into a UI component. * @@ -32,9 +36,11 @@ 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 SceneContext sceneContext = context.getSceneContext(); + 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); @@ -47,6 +53,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 +75,40 @@ 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 + final var copyMenuItem = new MenuItem("Copy Text"); + copyMenuItem.setOnAction(e -> copyMessageText(message.getText())); + items.add(copyMenuItem); + + // Delete message - if own message - action + if (ownMessage && client.isOnline()) { + final var deleteMenuItem = new MenuItem("Delete"); + deleteMenuItem.setOnAction(e -> 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 -> forwardMessage(message)); + items.add(forwardMenuItem); + + // Quote menu item + final var quoteMenuItem = new MenuItem("Quote"); + quoteMenuItem.setOnAction(e -> 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 @@ -98,7 +128,7 @@ public final class MessageControl extends Label { } final var saveAttachment = new MenuItem("Save attachment"); saveAttachment.setOnAction(e -> saveAttachment(message)); - contextMenu.getItems().add(saveAttachment); + items.add(saveAttachment); } // Creating the textLabel final var textLabel = new Label(message.getText()); @@ -116,12 +146,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)); @@ -131,11 +157,22 @@ public final class MessageControl extends Label { // Context Menu actions - private void copyMessage(Message message) { - Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(message.getText()), null); + private void copyMessageText(String text) { + logger.log(Level.FINEST, "A copy of message text \"" + text + "\" was requested"); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null); } - private void deleteMessage(Message message) { logger.log(Level.FINEST, "message deletion was requested for " + message); } + private void deleteMessage(Message message) { + final var messageDeletionEvent = new MessageDeletion(message.getID()); + messageDeletionEvent.setOwnEvent(); + + // Removing the message locally + EventBus.getInstance().dispatch(messageDeletionEvent); + + // Removing the message on the server and this chat's recipients + Context.getInstance().getClient().send(messageDeletionEvent); + 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); } 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..04332f0 100644 --- a/client/src/main/java/envoy/client/ui/controller/ChatScene.java +++ b/client/src/main/java/envoy/client/ui/controller/ChatScene.java @@ -308,6 +308,15 @@ public final class ChatScene implements EventListener, Restorable { @Event(eventType = Logout.class, priority = 200) private void onLogout() { eventBus.removeListener(this); } + @Event(priority = 200) + private void onMessageDeletion(MessageDeletion message) { + + // Clearing the selection if the own user was the sender of this event + if (message.isOwnEvent()) Platform.runLater(() -> { + messageList.getSelectionModel().clearSelection(); + }); + } + /** * Initializes all {@code SystemCommands} used in {@code ChatScene}. * diff --git a/common/src/main/java/envoy/event/MessageDeletion.java b/common/src/main/java/envoy/event/MessageDeletion.java new file mode 100644 index 0000000..c56a68c --- /dev/null +++ b/common/src/main/java/envoy/event/MessageDeletion.java @@ -0,0 +1,34 @@ +package envoy.event; + +/** + * Conveys the deletion of a message between clients and server. + * + * @author Leon Hofmeister + * @since Envoy Common v0.3-beta + */ +public class MessageDeletion extends Event { + + private static final long serialVersionUID = 1L; + + private transient boolean ownEvent; + + /** + * @param messageID the ID of the deleted message + * @since Envoy Common v0.3-beta + */ + public MessageDeletion(long messageID) { super(messageID); } + + /** + * @return whether the current user was the creator of this event. + * @since Envoy Common v0.3-beta + */ + public boolean isOwnEvent() { return ownEvent; } + + /** + * Marks this event as being sent by this user. Is needed for a bug free + * and efficient selection clearing. + * + * @since Envoy Common v0.3-beta + */ + public void setOwnEvent() { ownEvent = true; } +} diff --git a/server/src/main/java/envoy/server/Startup.java b/server/src/main/java/envoy/server/Startup.java index 6beafee..e6958aa 100755 --- a/server/src/main/java/envoy/server/Startup.java +++ b/server/src/main/java/envoy/server/Startup.java @@ -56,7 +56,8 @@ public final class Startup { new NameChangeProcessor(), new ProfilePicChangeProcessor(), new PasswordChangeRequestProcessor(), - new IssueProposalProcessor()))); + new IssueProposalProcessor(), + new MessageDeletionProcessor()))); // Initialize the current message ID final var persistenceManager = PersistenceManager.getInstance(); diff --git a/server/src/main/java/envoy/server/data/Contact.java b/server/src/main/java/envoy/server/data/Contact.java index 36a302f..b95abf8 100644 --- a/server/src/main/java/envoy/server/data/Contact.java +++ b/server/src/main/java/envoy/server/data/Contact.java @@ -1,7 +1,7 @@ package envoy.server.data; import java.time.Instant; -import java.util.Set; +import java.util.*; import javax.persistence.*; @@ -98,6 +98,34 @@ public abstract class Contact { */ public void setCreationDate(Instant creationDate) { this.creationDate = creationDate; } + /** + * Shortcut to convert a {@code Contact} into a {@code User}. + * + * @param contact the contact to convert + * @return the casted contact + * @throws IllegalStateException if the given contact is not a User + * @since Envoy Server v0.3-beta + */ + public static User toUser(Contact contact) { + if (!(contact instanceof User)) throw new IllegalStateException("Cannot cast a non user to a user"); + return (User) contact; + } + + /** + * Shortcut to convert a set of {@code Contact}s into a set of {@code User}s. + * + * @param contacts the contacts to convert + * @return the casted contacts + * @throws IllegalStateException if one of the given contacts is not a User + * @since Envoy Server v0.3-beta + */ + public static Set toUser(Set contacts) { + final var newSet = new HashSet(); + for (final var contact : contacts) + newSet.add(toUser(contact)); + return newSet; + } + @Override public String toString() { return String.format("%s[id=%d,name=%s,%d contact(s)]", getClass().getSimpleName(), id, name, contacts.size()); } } diff --git a/server/src/main/java/envoy/server/data/MessageDeletion.java b/server/src/main/java/envoy/server/data/MessageDeletion.java new file mode 100644 index 0000000..95c1637 --- /dev/null +++ b/server/src/main/java/envoy/server/data/MessageDeletion.java @@ -0,0 +1,81 @@ +package envoy.server.data; + +import java.util.*; + +import javax.persistence.*; + +/** + * Defines a message that has been deleted. + * + * @author Leon Hofmeister + * @since Envoy Server v0.3-beta + */ +@Entity +@Table(name = "deletionEvents") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +public final class MessageDeletion { + + @Id + @GeneratedValue + protected long messageID; + + @ManyToOne(targetEntity = User.class) + protected Set recipientsToInform; + + /** + * Creates an instance of {@code DeletionEvent}. + * + * @since Envoy Server v0.3-beta + */ + public MessageDeletion() {} + + /** + * Creates an instance of {@code MessageDeletion}. + * + * @param messageID the ID of the message + * @param recipientsToInform the recipientsToInform of the message
+ * that have not yet been notified of its + * deletion + * @since Envoy Server v0.3-beta + */ + public MessageDeletion(long messageID, Set recipientsToInform) { + this.messageID = messageID; + this.recipientsToInform = recipientsToInform; + } + + /** + * @return the messageID + * @since Envoy Server v0.3-beta + */ + public long getMessageID() { return messageID; } + + /** + * @param messageID the messageID to set + * @since Envoy Server v0.3-beta + */ + public void setMessageID(long messageID) { this.messageID = messageID; } + + /** + * @return the recipients that have yet to be informed + * @since Envoy Server v0.3-beta + */ + public Set getRecipientsToInform() { return recipientsToInform; } + + /** + * @param recipientsToInform the recipients that have yet to be informed + * @since Envoy Server v0.3-beta + */ + public void setRecipientsToInform(Set recipientsToInform) { this.recipientsToInform = recipientsToInform; } + + /** + * @param user the user who has been informed of the message deletion + * @since Envoy Server v0.3-beta + */ + public void recipientInformed(User user) { recipientsToInform.remove(user); } + + /** + * @param users the users that have been informed of the message deletion + * @since Envoy Server v0.3-beta + */ + public void recipientInformed(Collection users) { recipientsToInform.removeAll(users); } +} diff --git a/server/src/main/java/envoy/server/data/PersistenceManager.java b/server/src/main/java/envoy/server/data/PersistenceManager.java index f601fe5..7ae9f1d 100755 --- a/server/src/main/java/envoy/server/data/PersistenceManager.java +++ b/server/src/main/java/envoy/server/data/PersistenceManager.java @@ -1,7 +1,7 @@ package envoy.server.data; import java.time.Instant; -import java.util.List; +import java.util.*; import javax.persistence.*; @@ -100,12 +100,35 @@ public final class PersistenceManager { public void deleteContact(Contact contact) { remove(contact); } /** - * Deletes a {@link Message} in the database. + * Deletes a {@link Message} in the database and creates a new + * {@link MessageDeletion} object for all recipients of the + * message. * * @param message the {@link Message} to delete - * @since Envoy Server Standalone v0.1-alpha + * @return the created {@link MessageDeletion} object + * @since Envoy Server v0.3-beta */ - public void deleteMessage(Message message) { remove(message); } + public MessageDeletion deleteMessage(Message message) { + final var recipient = message.getRecipient(); + return deleteMessage(message, + recipient instanceof Group ? Contact.toUser(getGroupByID(recipient.id).getContacts()) : Set.of(Contact.toUser(recipient))); + } + + /** + * Deletes a {@link Message} in the database and creates a new + * {@link MessageDeletion} object for the given recipients of the message. + * + * @param message the {@link Message} to delete + * @param recipientsYetToInform the (sub)set of all recipients of that message + * @return the created {@link MessageDeletion} object + * @since Envoy Server v0.3-beta + */ + public MessageDeletion deleteMessage(Message message, Set recipientsYetToInform) { + final MessageDeletion deletion = new MessageDeletion(message.id, recipientsYetToInform); + persist(deletion); + remove(message); + return deletion; + } /** * Searches for a {@link User} with a specific ID. @@ -172,6 +195,16 @@ public final class PersistenceManager { */ public ConfigItem getConfigItemByID(String key) { return entityManager.find(ConfigItem.class, key); } + /** + * Searches for a {@link MessageDeletion} with the given message id. + * + * @param id the id of the message to search for + * @return the message deletion object with the specified ID or {@code null} if + * none is found + * @since Envoy Server v0.3-beta + */ + public MessageDeletion getMessageDeletionByID(long id) { return entityManager.find(MessageDeletion.class, id); } + /** * Returns all messages received while being offline or the ones that have * changed. diff --git a/server/src/main/java/envoy/server/processors/MessageDeletionProcessor.java b/server/src/main/java/envoy/server/processors/MessageDeletionProcessor.java new file mode 100644 index 0000000..d8de0b9 --- /dev/null +++ b/server/src/main/java/envoy/server/processors/MessageDeletionProcessor.java @@ -0,0 +1,18 @@ +package envoy.server.processors; + +import java.io.IOException; + +import envoy.event.MessageDeletion; +import envoy.server.net.ObjectWriteProxy; + +/** + * Listens for and handles incoming {@link MessageDeletion}s. + * + * @author Leon Hofmeister + * @since Envoy Server v0.3-beta + */ +public class MessageDeletionProcessor implements ObjectProcessor { + + @Override + public void process(MessageDeletion message, long socketID, ObjectWriteProxy writeProxy) throws IOException {} +}