diff --git a/client/src/main/java/envoy/client/data/Chat.java b/client/src/main/java/envoy/client/data/Chat.java index 80b2a97..6b0bd83 100644 --- a/client/src/main/java/envoy/client/data/Chat.java +++ b/client/src/main/java/envoy/client/data/Chat.java @@ -21,11 +21,12 @@ import envoy.event.MessageStatusChange; */ public class Chat implements Serializable { - protected final Contact recipient; - protected transient ObservableList messages = FXCollections.observableArrayList(); - protected int unreadAmount; + protected int unreadAmount; + protected boolean disabled; + + protected final Contact recipient; /** * Stores the last time an {@link envoy.event.IsTyping} event has been sent. @@ -55,7 +56,15 @@ public class Chat implements Serializable { } @Override - public String toString() { return String.format("%s[recipient=%s,messages=%d]", getClass().getSimpleName(), recipient, messages.size()); } + public String toString() { + return String.format( + "%s[recipient=%s,messages=%d,disabled=%b]", + getClass().getSimpleName(), + recipient, + messages.size(), + disabled + ); + } /** * Generates a hash code based on the recipient. @@ -90,11 +99,13 @@ public class Chat implements Serializable { public void read(WriteProxy writeProxy) { for (int i = messages.size() - 1; i >= 0; --i) { final var m = messages.get(i); - if (m.getSenderID() == recipient.getID()) if (m.getStatus() == MessageStatus.READ) break; - else { - m.setStatus(MessageStatus.READ); - writeProxy.writeMessageStatusChange(new MessageStatusChange(m)); - } + if (m.getSenderID() == recipient.getID()) + if (m.getStatus() == MessageStatus.READ) + break; + else { + m.setStatus(MessageStatus.READ); + writeProxy.writeMessageStatusChange(new MessageStatusChange(m)); + } } unreadAmount = 0; } @@ -168,4 +179,22 @@ public class Chat implements Serializable { * @since Envoy Client v0.2-beta */ public void lastWritingEventWasNow() { lastWritingEvent = System.currentTimeMillis(); } + + /** + * Determines whether messages can be sent in this chat. Should be {@code true} + * i.e. for chats whose recipient deleted this client as a contact. + * + * @return whether this chat has been disabled + * @since Envoy Client v0.3-beta + */ + public boolean isDisabled() { return disabled; } + + /** + * Determines whether messages can be sent in this chat. Should be true i.e. for + * chats whose recipient deleted this client as a contact. + * + * @param disabled whether this chat should be disabled + * @since Envoy Client v0.3-beta + */ + public void setDisabled(boolean disabled) { this.disabled = disabled; } } diff --git a/client/src/main/java/envoy/client/data/GroupChat.java b/client/src/main/java/envoy/client/data/GroupChat.java index 188709c..049c32e 100644 --- a/client/src/main/java/envoy/client/data/GroupChat.java +++ b/client/src/main/java/envoy/client/data/GroupChat.java @@ -25,7 +25,7 @@ public final class GroupChat extends Chat { * @param recipient the group whose members receive the messages * @since Envoy Client v0.1-beta */ - public GroupChat(User sender, Contact recipient) { + public GroupChat(User sender, Group recipient) { super(recipient); this.sender = sender; } diff --git a/client/src/main/java/envoy/client/data/LocalDB.java b/client/src/main/java/envoy/client/data/LocalDB.java index 13db9df..a18c3e9 100644 --- a/client/src/main/java/envoy/client/data/LocalDB.java +++ b/client/src/main/java/envoy/client/data/LocalDB.java @@ -1,11 +1,14 @@ package envoy.client.data; +import static java.util.function.Predicate.not; + import java.io.*; import java.nio.channels.*; import java.nio.file.StandardOpenOption; import java.time.Instant; import java.util.*; import java.util.logging.*; +import java.util.stream.Stream; import javafx.application.Platform; import javafx.collections.*; @@ -14,6 +17,7 @@ import envoy.client.event.*; import envoy.data.*; import envoy.data.Message.MessageStatus; import envoy.event.*; +import envoy.event.contact.*; import envoy.exception.EnvoyException; import envoy.util.*; @@ -39,6 +43,7 @@ public final class LocalDB implements EventListener { private IDGenerator idGenerator; private CacheMap cacheMap = new CacheMap(); private String authToken; + private boolean contactsChanged; // Auto save timer private Timer autoSaver; @@ -136,7 +141,32 @@ public final class LocalDB implements EventListener { if (user == null) throw new IllegalStateException("Client user is null, cannot initialize user storage"); userFile = new File(dbDir, user.getID() + ".db"); try (var in = new ObjectInputStream(new FileInputStream(userFile))) { - chats = FXCollections.observableList((List) in.readObject()); + chats = FXCollections.observableList((List) in.readObject()); + + // Some chats have changed and should not be overwritten by the saved values + if (contactsChanged) { + final var contacts = user.getContacts(); + + // Mark chats as disabled if a contact is no longer in this users contact list + final var changedUserChats = chats.stream() + .filter(not(chat -> contacts.contains(chat.getRecipient()))) + .peek(chat -> { chat.setDisabled(true); logger.log(Level.INFO, String.format("Deleted chat with %s.", chat.getRecipient())); }); + + // Also update groups with a different member count + final var changedGroupChats = contacts.stream().filter(Group.class::isInstance).flatMap(group -> { + final var potentialChat = getChat(group.getID()); + if (potentialChat.isEmpty()) return Stream.empty(); + final var chat = potentialChat.get(); + if (group.getContacts().size() != chat.getRecipient().getContacts().size()) { + logger.log(Level.INFO, "Removed one (or more) members from " + group); + return Stream.of(chat); + } else return Stream.empty(); + }); + Stream.concat(changedUserChats, changedGroupChats).forEach(chat -> chats.set(chats.indexOf(chat), chat)); + + // loadUserData can get called two (or more?) times during application lifecycle + contactsChanged = false; + } cacheMap = (CacheMap) in.readObject(); lastSync = (Instant) in.readObject(); } finally { @@ -163,7 +193,7 @@ public final class LocalDB implements EventListener { user.getContacts() .stream() .filter(c -> !c.equals(user) && getChat(c.getID()).isEmpty()) - .map(c -> c instanceof User ? new Chat(c) : new GroupChat(user, c)) + .map(c -> c instanceof User ? new Chat(c) : new GroupChat(user, (Group) c)) .forEach(chats::add); } @@ -195,9 +225,9 @@ public final class LocalDB implements EventListener { * @throws IOException if the saving process failed * @since Envoy Client v0.3-alpha */ - @Event(eventType = EnvoyCloseEvent.class, priority = 1000) + @Event(eventType = EnvoyCloseEvent.class, priority = 500) private synchronized void save() { - EnvoyLog.getLogger(LocalDB.class).log(Level.INFO, "Saving local database..."); + EnvoyLog.getLogger(LocalDB.class).log(Level.FINER, "Saving local database..."); // Save users try { @@ -217,33 +247,57 @@ public final class LocalDB implements EventListener { } } - @Event(priority = 150) + @Event(priority = 500) private void onMessage(Message msg) { if (msg.getStatus() == MessageStatus.SENT) msg.nextStatus(); } - @Event(priority = 150) + @Event(priority = 500) private void onGroupMessage(GroupMessage msg) { // TODO: Cancel event once EventBus is updated if (msg.getStatus() == MessageStatus.WAITING || msg.getStatus() == MessageStatus.READ) logger.warning("The groupMessage has the unexpected status " + msg.getStatus()); } - @Event(priority = 150) + @Event(priority = 500) private void onMessageStatusChange(MessageStatusChange evt) { getMessage(evt.getID()).ifPresent(msg -> msg.setStatus(evt.get())); } - @Event(priority = 150) + @Event(priority = 500) private void onGroupMessageStatusChange(GroupMessageStatusChange evt) { this.getMessage(evt.getID()).ifPresent(msg -> msg.getMemberStatuses().replace(evt.getMemberID(), evt.get())); } - @Event(priority = 150) + @Event(priority = 500) private void onUserStatusChange(UserStatusChange evt) { getChat(evt.getID()).map(Chat::getRecipient).map(User.class::cast).ifPresent(u -> u.setStatus(evt.get())); } - @Event(priority = 150) + @Event(priority = 500) + private void onUserOperation(UserOperation operation) { + final var eventUser = operation.get(); + switch (operation.getOperationType()) { + case ADD: + Platform.runLater(() -> chats.add(0, new Chat(eventUser))); + break; + case REMOVE: + getChat(eventUser.getID()).ifPresent(chat -> chat.setDisabled(true)); + break; + } + } + + @Event + private void onGroupCreationResult(GroupCreationResult evt) { + final var newGroup = evt.get(); + + // The group creation was not successful + if (newGroup == null) return; + + // The group was successfully created + else Platform.runLater(() -> chats.add(new GroupChat(user, newGroup))); + } + + @Event(priority = 500) private void onGroupResize(GroupResize evt) { getChat(evt.getGroupID()).map(Chat::getRecipient).map(Group.class::cast).ifPresent(evt::apply); } - @Event(priority = 150) + @Event(priority = 500) private void onNameChange(NameChange evt) { chats.stream().map(Chat::getRecipient).filter(c -> c.getID() == evt.getID()).findAny().ifPresent(c -> c.setName(evt.get())); } @@ -262,7 +316,7 @@ public final class LocalDB implements EventListener { * * @since Envoy Client v0.2-beta */ - @Event(eventType = Logout.class, priority = 100) + @Event(eventType = Logout.class, priority = 50) private void onLogout() { autoSaver.cancel(); autoSaveRestart = true; @@ -296,6 +350,12 @@ public final class LocalDB implements EventListener { @Event(priority = 500) private void onOwnStatusChange(OwnStatusChange statusChange) { user.setStatus(statusChange.get()); } + @Event(eventType = ContactsChangedSinceLastLogin.class, priority = 500) + private void onContactsChangedSinceLastLogin() { contactsChanged = true; } + + @Event(priority = 500) + private void onContactDisabled(ContactDisabled event) { getChat(event.get().getID()).ifPresent(chat -> chat.setDisabled(true)); } + /** * @return a {@code Map} of all users stored locally with their * user names as keys diff --git a/client/src/main/java/envoy/client/data/Settings.java b/client/src/main/java/envoy/client/data/Settings.java index 9b317cd..8f56774 100644 --- a/client/src/main/java/envoy/client/data/Settings.java +++ b/client/src/main/java/envoy/client/data/Settings.java @@ -68,7 +68,7 @@ public final class Settings implements EventListener { * @throws IOException if an error occurs while saving the themes * @since Envoy Client v0.2-alpha */ - @Event(eventType = EnvoyCloseEvent.class, priority = 900) + @Event(eventType = EnvoyCloseEvent.class) private void save() { EnvoyLog.getLogger(Settings.class).log(Level.INFO, "Saving settings..."); diff --git a/client/src/main/java/envoy/client/data/shortcuts/EnvoyShortcutConfig.java b/client/src/main/java/envoy/client/data/shortcuts/EnvoyShortcutConfig.java index 14a110c..0f58e97 100644 --- a/client/src/main/java/envoy/client/data/shortcuts/EnvoyShortcutConfig.java +++ b/client/src/main/java/envoy/client/data/shortcuts/EnvoyShortcutConfig.java @@ -6,6 +6,7 @@ import envoy.client.data.Context; import envoy.client.helper.ShutdownHelper; import envoy.client.ui.SceneContext.SceneInfo; import envoy.client.util.UserUtil; +import envoy.data.User.UserStatus; /** * Envoy-specific implementation of the keyboard-shortcut interaction offered by @@ -40,5 +41,25 @@ public class EnvoyShortcutConfig { () -> Context.getInstance().getSceneContext().load(SceneInfo.SETTINGS_SCENE), SceneInfo.SETTINGS_SCENE, SceneInfo.LOGIN_SCENE); + + // Add option to change to status away + instance.addForNotExcluded(new KeyCodeCombination(KeyCode.A, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN), + () -> UserUtil.changeStatus(UserStatus.AWAY), + SceneInfo.LOGIN_SCENE); + + // Add option to change to status busy + instance.addForNotExcluded(new KeyCodeCombination(KeyCode.B, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN), + () -> UserUtil.changeStatus(UserStatus.BUSY), + SceneInfo.LOGIN_SCENE); + + // Add option to change to status offline + instance.addForNotExcluded(new KeyCodeCombination(KeyCode.F, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN), + () -> UserUtil.changeStatus(UserStatus.OFFLINE), + SceneInfo.LOGIN_SCENE); + + // Add option to change to status online + instance.addForNotExcluded(new KeyCodeCombination(KeyCode.N, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN), + () -> UserUtil.changeStatus(UserStatus.ONLINE), + SceneInfo.LOGIN_SCENE); } } diff --git a/client/src/main/java/envoy/client/event/ContactDisabled.java b/client/src/main/java/envoy/client/event/ContactDisabled.java new file mode 100644 index 0000000..c3721fe --- /dev/null +++ b/client/src/main/java/envoy/client/event/ContactDisabled.java @@ -0,0 +1,21 @@ +package envoy.client.event; + +import envoy.data.Contact; +import envoy.event.Event; + +/** + * Signifies that the chat of a contact should be disabled. + * + * @author Leon Hofmeister + * @since Envoy Client v0.3-beta + */ +public class ContactDisabled extends Event { + + private static final long serialVersionUID = 1L; + + /** + * @param contact the contact that should be disabled + * @since Envoy Client v0.3-beta + */ + public ContactDisabled(Contact contact) { super(contact); } +} diff --git a/client/src/main/java/envoy/client/net/Client.java b/client/src/main/java/envoy/client/net/Client.java index 0db43ef..3b886eb 100644 --- a/client/src/main/java/envoy/client/net/Client.java +++ b/client/src/main/java/envoy/client/net/Client.java @@ -61,6 +61,7 @@ public final class Client implements EventListener, Closeable { */ public void performHandshake(LoginCredentials credentials, CacheMap cacheMap) throws TimeoutException, IOException, InterruptedException { if (online) throw new IllegalStateException("Handshake has already been performed successfully"); + rejected = false; // Establish TCP connection logger.log(Level.FINER, String.format("Attempting connection to server %s:%d...", config.getServer(), config.getPort())); @@ -75,8 +76,6 @@ public final class Client implements EventListener, Closeable { receiver.registerProcessor(User.class, sender -> this.sender = sender); receiver.registerProcessors(cacheMap.getMap()); - rejected = false; - // Start receiver receiver.start(); @@ -95,7 +94,10 @@ public final class Client implements EventListener, Closeable { return; } - if (System.currentTimeMillis() - start > 5000) throw new TimeoutException("Did not log in after 5 seconds"); + if (System.currentTimeMillis() - start > 5000) { + rejected = true; + throw new TimeoutException("Did not log in after 5 seconds"); + } Thread.sleep(500); } @@ -146,7 +148,7 @@ public final class Client implements EventListener, Closeable { logger.log(Level.FINE, "Sending " + obj); try { SerializationUtils.writeBytesWithLength(obj, socket.getOutputStream()); - } catch (IOException e) { + } catch (final IOException e) { throw new RuntimeException(e); } } @@ -177,7 +179,7 @@ public final class Client implements EventListener, Closeable { private void onHandshakeRejection() { rejected = true; } @Override - @Event(eventType = EnvoyCloseEvent.class, priority = 800) + @Event(eventType = EnvoyCloseEvent.class, priority = 50) public void close() { if (online) { logger.log(Level.INFO, "Closing connection..."); diff --git a/client/src/main/java/envoy/client/ui/control/GroupSizeLabel.java b/client/src/main/java/envoy/client/ui/control/GroupSizeLabel.java index 8413744..120206d 100644 --- a/client/src/main/java/envoy/client/ui/control/GroupSizeLabel.java +++ b/client/src/main/java/envoy/client/ui/control/GroupSizeLabel.java @@ -16,5 +16,7 @@ public final class GroupSizeLabel extends Label { * @param recipient the group whose members to show * @since Envoy Client v0.3-beta */ - public GroupSizeLabel(Group recipient) { super(recipient.getContacts().size() + " members"); } + public GroupSizeLabel(Group recipient) { + super(recipient.getContacts().size() + " member" + (recipient.getContacts().size() != 1 ? "s" : "")); + } } 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 c2b0ef1..e472380 100644 --- a/client/src/main/java/envoy/client/ui/controller/ChatScene.java +++ b/client/src/main/java/envoy/client/ui/controller/ChatScene.java @@ -37,7 +37,7 @@ import envoy.data.*; import envoy.data.Attachment.AttachmentType; import envoy.data.Message.MessageStatus; import envoy.event.*; -import envoy.event.contact.ContactOperation; +import envoy.event.contact.UserOperation; import envoy.exception.EnvoyException; import envoy.util.EnvoyLog; @@ -91,9 +91,6 @@ public final class ChatScene implements EventListener, Restorable { @FXML private Label topBarStatusLabel; - @FXML - private MenuItem deleteContactMenuItem; - @FXML private ImageView attachmentView; @@ -165,7 +162,7 @@ public final class ChatScene implements EventListener, Restorable { // Initialize message and user rendering messageList.setCellFactory(MessageListCell::new); - chatList.setCellFactory(new ListCellFactory<>(ChatControl::new)); + chatList.setCellFactory(ChatListCell::new); // JavaFX provides an internal way of populating the context menu of a text // area. @@ -191,7 +188,6 @@ public final class ChatScene implements EventListener, Restorable { // Set the design of the box in the upper-left corner settingsButton.setAlignment(Pos.BOTTOM_RIGHT); - HBox.setHgrow(spaceBetweenUserAndSettingsButton, Priority.ALWAYS); generateOwnStatusControl(); Platform.runLater(() -> { @@ -271,18 +267,22 @@ public final class ChatScene implements EventListener, Restorable { } @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; - } + private void onUserOperation(UserOperation operation) { + + // All ADD dependent logic resides in LocalDB + if (operation.getOperationType().equals(ElementOperation.REMOVE)) Platform.runLater(() -> disableChat(new ContactDisabled(operation.get()))); + } + + @Event + private void onGroupResize(GroupResize resize) { + final var chatFound = localDB.getChat(resize.getGroupID()); + chatFound.ifPresent(chat -> Platform.runLater(() -> { + chatList.refresh(); + + // Update the top-bar status label if all conditions apply + if (currentChat != null && currentChat.getRecipient().equals(chat.getRecipient())) topBarStatusLabel + .setText(chat.getRecipient().getContacts().size() + " member" + (currentChat.getRecipient().getContacts().size() != 1 ? "s" : "")); + })); } @Event(eventType = NoAttachments.class) @@ -298,8 +298,8 @@ public final class ChatScene implements EventListener, Restorable { }); } - @Event - private void onGroupCreationResult(GroupCreationResult result) { Platform.runLater(() -> newGroupButton.setDisable(!result.get())); } + @Event(priority = 150) + private void onGroupCreationResult(GroupCreationResult result) { Platform.runLater(() -> newGroupButton.setDisable(result.get() == null)); } @Event(eventType = ThemeChangeEvent.class) private void onThemeChange() { @@ -312,7 +312,6 @@ public final class ChatScene implements EventListener, Restorable { clientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); chatList.setCellFactory(new ListCellFactory<>(ChatControl::new)); messageList.setCellFactory(MessageListCell::new); - // TODO: cache image if (currentChat != null) if (currentChat.getRecipient() instanceof User) recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); else recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43)); @@ -332,8 +331,10 @@ public final class ChatScene implements EventListener, Restorable { @FXML private void chatListClicked() { if (chatList.getSelectionModel().isEmpty()) return; + final var chat = chatList.getSelectionModel().getSelectedItem(); + if (chat == null) return; - final var user = chatList.getSelectionModel().getSelectedItem().getRecipient(); + final var user = chat.getRecipient(); if (user != null && (currentChat == null || !user.equals(currentChat.getRecipient()))) { // LEON: JFC <===> JAVA FRIED CHICKEN <=/=> Java Foundation Classes @@ -345,7 +346,6 @@ public final class ChatScene implements EventListener, Restorable { 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); @@ -363,20 +363,28 @@ public final class ChatScene implements EventListener, Restorable { 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); + + // Enable or disable the necessary UI controls + final var chatEditable = currentChat == null || currentChat.isDisabled(); + messageTextArea.setDisable(chatEditable || postingPermanentlyDisabled); + voiceButton.setDisable(!recorder.isSupported() || chatEditable); + attachmentButton.setDisable(chatEditable); chatList.refresh(); + // Design the top bar if (currentChat != null) { topBarContactLabel.setText(currentChat.getRecipient().getName()); + topBarContactLabel.setVisible(true); + topBarStatusLabel.setVisible(true); if (currentChat.getRecipient() instanceof User) { final var status = ((User) currentChat.getRecipient()).getStatus().toString(); topBarStatusLabel.setText(status); + topBarStatusLabel.getStyleClass().clear(); topBarStatusLabel.getStyleClass().add(status.toLowerCase()); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43)); } else { - topBarStatusLabel.setText(currentChat.getRecipient().getContacts().size() + " members"); + topBarStatusLabel.setText(currentChat.getRecipient().getContacts().size() + " member" + + (currentChat.getRecipient().getContacts().size() != 1 ? "s" : "")); topBarStatusLabel.getStyleClass().clear(); recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43)); } @@ -386,7 +394,6 @@ public final class ChatScene implements EventListener, Restorable { clip.setArcHeight(43); clip.setArcWidth(43); recipientProfilePic.setClip(clip); - messageSearchButton.setVisible(true); } } @@ -665,9 +672,9 @@ public final class ChatScene implements EventListener, Restorable { 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); + chatList.getSelectionModel().select(0); }); scrollToMessageListEnd(); @@ -712,7 +719,8 @@ public final class ChatScene implements EventListener, Restorable { * @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); + if (!(attachmentView.getImage() == null || attachmentView.getImage().equals(DEFAULT_ATTACHMENT_VIEW_IMAGE))) + attachmentView.setImage(DEFAULT_ATTACHMENT_VIEW_IMAGE); attachmentView.setVisible(visible); } @@ -727,14 +735,59 @@ public final class ChatScene implements EventListener, Restorable { // Else prepend it to the HBox children final var ownUserControl = new ContactControl(localDB.getUser()); ownUserControl.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(ownUserControl, Priority.NEVER); ownContactControl.getChildren().add(0, ownUserControl); } } - // Context menu actions + /** + * Redesigns the UI when the {@link Chat} of the given contact has been marked + * as disabled. + * + * @param event the contact whose chat got disabled + * @since Envoy Client v0.3-beta + */ + @Event + public void disableChat(ContactDisabled event) { + chatList.refresh(); + final var recipient = event.get(); - @FXML - private void deleteContact() { try {} catch (final NullPointerException e) {} } + // Decrement member count for groups + if (recipient instanceof Group) + topBarStatusLabel.setText(recipient.getContacts().size() + " member" + (recipient.getContacts().size() != 1 ? "s" : "")); + if (currentChat != null && currentChat.getRecipient().equals(recipient)) { + messageTextArea.setDisable(true); + voiceButton.setDisable(true); + attachmentButton.setDisable(true); + pendingAttachment = null; + messageList.getStyleClass().clear(); + messageList.getStyleClass().add("disabled-chat"); + } + } + + /** + * Resets every component back to its inital state before a chat was selected. + * + * @since Envoy Client v0.3-beta + */ + public void resetState() { + currentChat = null; + chatList.getSelectionModel().clearSelection(); + messageList.getItems().clear(); + messageTextArea.setDisable(true); + attachmentView.setImage(null); + topBarContactLabel.setVisible(false); + topBarStatusLabel.setVisible(false); + messageSearchButton.setVisible(false); + messageTextArea.clear(); + messageTextArea.setDisable(true); + attachmentButton.setDisable(true); + voiceButton.setDisable(true); + remainingChars.setVisible(false); + pendingAttachment = null; + recipientProfilePic.setImage(null); + if (recorder.isRecording()) recorder.cancel(); + } @FXML private void copyAndPostMessage() { diff --git a/client/src/main/java/envoy/client/ui/controller/ContactSearchTab.java b/client/src/main/java/envoy/client/ui/controller/ContactSearchTab.java index b573345..a31b664 100644 --- a/client/src/main/java/envoy/client/ui/controller/ContactSearchTab.java +++ b/client/src/main/java/envoy/client/ui/controller/ContactSearchTab.java @@ -63,7 +63,7 @@ public class ContactSearchTab implements EventListener { } @Event - private void onContactOperation(ContactOperation operation) { + private void onUserOperation(UserOperation operation) { final var contact = operation.get(); if (operation.getOperationType() == ElementOperation.ADD) Platform.runLater(() -> { userList.getItems().remove(contact); @@ -96,7 +96,7 @@ public class ContactSearchTab implements EventListener { } /** - * Sends an {@link ContactOperation} for the selected user to the + * Sends an {@link UserOperation} for the selected user to the * server. * * @since Envoy Client v0.1-beta @@ -114,7 +114,7 @@ public class ContactSearchTab implements EventListener { private void addAsContact() { // Sends the event to the server - final var event = new ContactOperation(currentlySelectedUser, ElementOperation.ADD); + final var event = new UserOperation(currentlySelectedUser, ElementOperation.ADD); client.send(event); // Removes the chosen user and updates the UI @@ -124,5 +124,8 @@ public class ContactSearchTab implements EventListener { } @FXML - private void backButtonClicked() { eventBus.dispatch(new BackEvent()); } + private void backButtonClicked() { + searchBar.setText(""); + eventBus.dispatch(new BackEvent()); + } } diff --git a/client/src/main/java/envoy/client/ui/controller/GroupCreationTab.java b/client/src/main/java/envoy/client/ui/controller/GroupCreationTab.java index 23ec485..ce085a7 100644 --- a/client/src/main/java/envoy/client/ui/controller/GroupCreationTab.java +++ b/client/src/main/java/envoy/client/ui/controller/GroupCreationTab.java @@ -16,7 +16,7 @@ import envoy.client.ui.control.*; import envoy.client.ui.listcell.ListCellFactory; import envoy.data.*; import envoy.event.GroupCreation; -import envoy.event.contact.ContactOperation; +import envoy.event.contact.UserOperation; import envoy.util.Bounds; import dev.kske.eventbus.*; @@ -82,7 +82,7 @@ public class GroupCreationTab implements EventListener { .map(User.class::cast) .collect(Collectors.toList())); resizeQuickSelectSpace(0); - quickSelectList.addEventFilter(MouseEvent.MOUSE_PRESSED, evt -> evt.consume()); + quickSelectList.addEventFilter(MouseEvent.MOUSE_PRESSED, MouseEvent::consume); } /** @@ -169,7 +169,7 @@ public class GroupCreationTab implements EventListener { /** * Removes an element from the quickSelectList. - * + * * @param element the element to be removed. * @since Envoy Client v0.3-beta */ @@ -234,11 +234,11 @@ public class GroupCreationTab implements EventListener { } @Event - private void onContactOperation(ContactOperation operation) { - if (operation.get() instanceof User) Platform.runLater(() -> { + private void onUserOperation(UserOperation operation) { + Platform.runLater(() -> { switch (operation.getOperationType()) { case ADD: - userList.getItems().add((User) operation.get()); + userList.getItems().add(operation.get()); break; case REMOVE: userList.getItems().removeIf(operation.get()::equals); diff --git a/client/src/main/java/envoy/client/ui/listcell/AbstractListCell.java b/client/src/main/java/envoy/client/ui/listcell/AbstractListCell.java index 1e12021..842f0fd 100644 --- a/client/src/main/java/envoy/client/ui/listcell/AbstractListCell.java +++ b/client/src/main/java/envoy/client/ui/listcell/AbstractListCell.java @@ -33,6 +33,7 @@ public abstract class AbstractListCell extends ListCell { setGraphic(renderItem(item)); } else { setGraphic(null); + setCursor(Cursor.DEFAULT); } } diff --git a/client/src/main/java/envoy/client/ui/listcell/ChatListCell.java b/client/src/main/java/envoy/client/ui/listcell/ChatListCell.java new file mode 100644 index 0000000..ccaa6dd --- /dev/null +++ b/client/src/main/java/envoy/client/ui/listcell/ChatListCell.java @@ -0,0 +1,46 @@ +package envoy.client.ui.listcell; + +import javafx.scene.control.*; + +import envoy.client.data.*; +import envoy.client.net.Client; +import envoy.client.ui.control.ChatControl; +import envoy.client.util.UserUtil; +import envoy.data.User; + +/** + * A list cell containing chats represented as chat controls. + * + * @author Leon Hofmeister + * @since Envoy Client v0.3-beta + */ +public class ChatListCell extends AbstractListCell { + + private static final Client client = Context.getInstance().getClient(); + + /** + * @param listView the list view inside of which the cell will be displayed + * @since Envoy Client v0.3-beta + */ + public ChatListCell(ListView listView) { super(listView); } + + @Override + protected ChatControl renderItem(Chat chat) { + if (client.isOnline()) { + final var menu = new ContextMenu(); + final var removeMI = new MenuItem(); + removeMI.setText( + chat.isDisabled() ? "Delete " : chat.getRecipient() instanceof User ? "Block " : "Leave group " + chat.getRecipient().getName()); + removeMI.setOnAction( + chat.isDisabled() ? e -> UserUtil.deleteContact(chat.getRecipient()) : e -> UserUtil.disableContact(chat.getRecipient())); + menu.getItems().add(removeMI); + setContextMenu(menu); + } else setContextMenu(null); + + // TODO: replace with icon in ChatControl + final var chatControl = new ChatControl(chat); + if (chat.isDisabled()) chatControl.getStyleClass().add("disabled-chat"); + else chatControl.getStyleClass().remove("disabled-chat"); + return chatControl; + } +} diff --git a/client/src/main/java/envoy/client/util/UserUtil.java b/client/src/main/java/envoy/client/util/UserUtil.java index 4f1b37b..a15d9cc 100644 --- a/client/src/main/java/envoy/client/util/UserUtil.java +++ b/client/src/main/java/envoy/client/util/UserUtil.java @@ -1,6 +1,6 @@ package envoy.client.util; -import java.util.logging.Level; +import java.util.logging.*; import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; @@ -9,8 +9,11 @@ import envoy.client.data.Context; import envoy.client.event.*; import envoy.client.helper.*; import envoy.client.ui.SceneContext.SceneInfo; +import envoy.client.ui.controller.ChatScene; +import envoy.data.*; import envoy.data.User.UserStatus; -import envoy.event.UserStatusChange; +import envoy.event.*; +import envoy.event.contact.UserOperation; import envoy.util.EnvoyLog; import dev.kske.eventbus.EventBus; @@ -23,6 +26,9 @@ import dev.kske.eventbus.EventBus; */ public final class UserUtil { + private static final Context context = Context.getInstance(); + private static final Logger logger = EnvoyLog.getLogger(UserUtil.class); + private UserUtil() {} /** @@ -40,7 +46,8 @@ public final class UserUtil { EnvoyLog.getLogger(ShutdownHelper.class).log(Level.INFO, "A logout was requested"); EventBus.getInstance().dispatch(new EnvoyCloseEvent()); EventBus.getInstance().dispatch(new Logout()); - Context.getInstance().getSceneContext().load(SceneInfo.LOGIN_SCENE); + context.getSceneContext().load(SceneInfo.LOGIN_SCENE); + logger.log(Level.INFO, "A logout occurred."); }); } @@ -54,11 +61,56 @@ public final class UserUtil { public static void changeStatus(UserStatus newStatus) { // Sending the already active status is a valid action - if (newStatus.equals(Context.getInstance().getLocalDB().getUser().getStatus())) return; + if (newStatus.equals(context.getLocalDB().getUser().getStatus())) return; else { EventBus.getInstance().dispatch(new OwnStatusChange(newStatus)); - if (Context.getInstance().getClient().isOnline()) - Context.getInstance().getClient().send(new UserStatusChange(Context.getInstance().getLocalDB().getUser().getID(), newStatus)); + if (context.getClient().isOnline()) context.getClient().send(new UserStatusChange(context.getLocalDB().getUser().getID(), newStatus)); + logger.log(Level.INFO, "A manual status change occurred."); + } + } + + /** + * Removes the given contact. + * + * @param block the contact that should be removed + * @since Envoy Client v0.3-beta + */ + public static void disableContact(Contact block) { + if (!context.getClient().isOnline() || block == null) return; + else { + final var alert = new Alert(AlertType.CONFIRMATION); + alert.setContentText("Are you sure you want to " + (block instanceof User ? "block " : "leave group ") + block.getName() + "?"); + AlertHelper.confirmAction(alert, () -> { + final var isUser = block instanceof User; + context.getClient() + .send(isUser ? new UserOperation((User) block, ElementOperation.REMOVE) + : new GroupResize(context.getLocalDB().getUser(), (Group) block, ElementOperation.REMOVE)); + if (!isUser) block.getContacts().remove(context.getLocalDB().getUser()); + EventBus.getInstance().dispatch(new ContactDisabled(block)); + logger.log(Level.INFO, isUser ? "A user was blocked." : "The user left a group."); + }); + } + } + + /** + * Deletes the given contact with all his messages entirely. + * + * @param delete the contact to delete + * @since Envoy Client v0.3-beta + */ + public static void deleteContact(Contact delete) { + if (delete == null) return; + else { + final var alert = new Alert(AlertType.CONFIRMATION); + alert.setContentText("Are you sure you want to delete " + delete.getName() + + " entirely? All messages with this contact will be deleted. This action cannot be undone."); + AlertHelper.confirmAction(alert, () -> { + context.getLocalDB().getUsers().remove(delete.getName()); + context.getLocalDB().getChats().removeIf(chat -> chat.getRecipient().equals(delete)); + if (context.getSceneContext().getController() instanceof ChatScene) + ((ChatScene) context.getSceneContext().getController()).resetState(); + logger.log(Level.INFO, "A contact with all his messages was deleted."); + }); } } } diff --git a/client/src/main/resources/css/base.css b/client/src/main/resources/css/base.css index 28d276c..4c46161 100644 --- a/client/src/main/resources/css/base.css +++ b/client/src/main/resources/css/base.css @@ -139,20 +139,25 @@ .tab-pane { -fx-tab-max-height: 0.0 ; -} +} + .tab-pane .tab-header-area { visibility: hidden ; -fx-padding: -20.0 0.0 0.0 0.0; } +.disabled-chat { + -fx-background-color: #0000FF; +} + #quick-select-list .scroll-bar:horizontal{ - -fx-pref-height: 0; - -fx-max-height: 0; - -fx-min-height: 0; + -fx-pref-height: 0.0; + -fx-max-height: 0.0; + -fx-min-height: 0.0; } #quick-select-list .scroll-bar:vertical{ - -fx-pref-width: 0; - -fx-max-width: 0; - -fx-min-width: 0; + -fx-pref-width: 0.0; + -fx-max-width: 0.0; + -fx-min-width: 0.0; } diff --git a/client/src/main/resources/fxml/ChatScene.fxml b/client/src/main/resources/fxml/ChatScene.fxml index d3fb6e9..d61325c 100644 --- a/client/src/main/resources/fxml/ChatScene.fxml +++ b/client/src/main/resources/fxml/ChatScene.fxml @@ -126,15 +126,6 @@ - - - - - - - @@ -167,7 +158,7 @@ + fx:id="spaceBetweenUserAndSettingsButton" HBox.hgrow="ALWAYS" />