Add Ability to Delete Messages Locally #70

Merged
delvh merged 6 commits from f/delete-messages into develop 2020-09-30 20:50:59 +02:00
10 changed files with 299 additions and 30 deletions
Showing only changes of commit 43981c9272 - Show all commits

View File

@ -121,6 +121,15 @@ public class Chat implements Serializable {
messages.add(0, message);
}
/**
* Removes the message with the given ID
delvh marked this conversation as resolved Outdated
Outdated
Review

Append a dot to the end of the sentence.

Append a dot to the end of the sentence.
*
* @param messageID the ID of the message to remove
* @return whether any message has been removed
delvh marked this conversation as resolved Outdated
Outdated
Review

You might consider changing this to "whether the message has been removed", as "any" implies the removal of random messages.

You might consider changing this to "whether the message has been removed", as "any" implies the removal of random messages.
Outdated
Review

While I do see your point, I'd also point out that the current tag is pretty much equivalent to Collection.removeIf, which only states that it returns true if any element was removed.
But okay, I can change it.

While I do see your point, I'd also point out that the current tag is pretty much equivalent to `Collection.removeIf`, which only states that it returns true if any element was removed. But okay, I can change it.
Outdated
Review

That's true, because Collection.removeIf acts on arbitrary collections, while a list of messages has the property that the message IDs are unique.

That's true, because `Collection.removeIf` acts on arbitrary collections, while a list of messages has the property that the message IDs are unique.
* @since Envoy Client v0.3-beta
*/
public boolean remove(long messageID) { return messages.removeIf(m -> m.getID() == messageID); }
/**
* Increments the amount of unread messages.
*

View File

@ -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.
delvh marked this conversation as resolved Outdated
Outdated
Review

Again, consider rewording this to "... if present", as there is no such thing as multiple messages with the same ID.

Again, consider rewording this to "... if present", as there is no such thing as multiple messages with the same ID.
*
* @param message the event that was
* @since Envoy Client v0.3-beta
*/
@Event()
delvh marked this conversation as resolved Outdated
Outdated
Review

The parentheses after @Event are unnecessary here.

The parentheses after `@Event` are unnecessary here.
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<String, User>} of all users stored locally with their
* user names as keys

View File

@ -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<MessageStatus, Image> 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); }

View File

@ -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}.
*

View File

@ -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<Long> {
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; }
}

View File

@ -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();

View File

@ -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<User> toUser(Set<Contact> contacts) {
final var newSet = new HashSet<User>();
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()); }
}

View File

@ -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<User> 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<br>
* <strong>that have not yet been notified of its
* deletion</strong>
* @since Envoy Server v0.3-beta
*/
public MessageDeletion(long messageID, Set<User> 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<User> getRecipientsToInform() { return recipientsToInform; }
/**
* @param recipientsToInform the recipients that have yet to be informed
* @since Envoy Server v0.3-beta
*/
public void setRecipientsToInform(Set<User> 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<User> users) { recipientsToInform.removeAll(users); }
}

View File

@ -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 <strong>all</strong> 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<User> 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.

View File

@ -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<MessageDeletion> {
@Override
public void process(MessageDeletion message, long socketID, ObjectWriteProxy writeProxy) throws IOException {}
}