Use ObservableList in LocalDB and Chat, reduce amount of UI refreshes
This commit is contained in:
		@@ -3,6 +3,8 @@ package envoy.client.data;
 | 
			
		||||
import java.io.*;
 | 
			
		||||
import java.util.*;
 | 
			
		||||
 | 
			
		||||
import javafx.collections.*;
 | 
			
		||||
 | 
			
		||||
import envoy.client.net.WriteProxy;
 | 
			
		||||
import envoy.data.*;
 | 
			
		||||
import envoy.data.Message.MessageStatus;
 | 
			
		||||
@@ -19,8 +21,9 @@ import envoy.event.MessageStatusChange;
 | 
			
		||||
 */
 | 
			
		||||
public class Chat implements Serializable {
 | 
			
		||||
 | 
			
		||||
	protected final Contact			recipient;
 | 
			
		||||
	protected final List<Message>	messages	= new ArrayList<>();
 | 
			
		||||
	protected final Contact recipient;
 | 
			
		||||
 | 
			
		||||
	protected transient ObservableList<Message> messages = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
	protected int unreadAmount;
 | 
			
		||||
 | 
			
		||||
@@ -29,7 +32,7 @@ public class Chat implements Serializable {
 | 
			
		||||
	 */
 | 
			
		||||
	protected transient long lastWritingEvent;
 | 
			
		||||
 | 
			
		||||
	private static final long serialVersionUID = 1L;
 | 
			
		||||
	private static final long serialVersionUID = 2L;
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Provides the list of messages that the recipient receives.
 | 
			
		||||
@@ -41,6 +44,16 @@ public class Chat implements Serializable {
 | 
			
		||||
	 */
 | 
			
		||||
	public Chat(Contact recipient) { this.recipient = recipient; }
 | 
			
		||||
 | 
			
		||||
	private void readObject(ObjectInputStream stream) throws ClassNotFoundException, IOException {
 | 
			
		||||
		stream.defaultReadObject();
 | 
			
		||||
		messages = FXCollections.observableList((List<Message>) stream.readObject());
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private void writeObject(ObjectOutputStream stream) throws IOException {
 | 
			
		||||
		stream.defaultWriteObject();
 | 
			
		||||
		stream.writeObject(new ArrayList<>(messages));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Override
 | 
			
		||||
	public String toString() { return String.format("%s[recipient=%s,messages=%d]", getClass().getSimpleName(), recipient, messages.size()); }
 | 
			
		||||
 | 
			
		||||
@@ -72,11 +85,9 @@ public class Chat implements Serializable {
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param writeProxy the write proxy instance used to notify the server about
 | 
			
		||||
	 *                   the message status changes
 | 
			
		||||
	 * @throws IOException if a {@link MessageStatusChange} could not be
 | 
			
		||||
	 *                     delivered to the server
 | 
			
		||||
	 * @since Envoy Client v0.3-alpha
 | 
			
		||||
	 */
 | 
			
		||||
	public void read(WriteProxy writeProxy) throws IOException {
 | 
			
		||||
	public void read(WriteProxy writeProxy) {
 | 
			
		||||
		for (int i = messages.size() - 1; i >= 0; --i) {
 | 
			
		||||
			final Message m = messages.get(i);
 | 
			
		||||
			if (m.getSenderID() == recipient.getID()) if (m.getStatus() == MessageStatus.READ) break;
 | 
			
		||||
@@ -127,7 +138,7 @@ public class Chat implements Serializable {
 | 
			
		||||
	 * @return all messages in the current chat
 | 
			
		||||
	 * @since Envoy Client v0.1-beta
 | 
			
		||||
	 */
 | 
			
		||||
	public List<Message> getMessages() { return messages; }
 | 
			
		||||
	public ObservableList<Message> getMessages() { return messages; }
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * @return the recipient of a message
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
package envoy.client.data;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.time.Instant;
 | 
			
		||||
 | 
			
		||||
import envoy.client.net.WriteProxy;
 | 
			
		||||
@@ -32,7 +31,7 @@ public final class GroupChat extends Chat {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Override
 | 
			
		||||
	public void read(WriteProxy writeProxy) throws IOException {
 | 
			
		||||
	public void read(WriteProxy writeProxy) {
 | 
			
		||||
		for (int i = messages.size() - 1; i >= 0; --i) {
 | 
			
		||||
			final GroupMessage gmsg = (GroupMessage) messages.get(i);
 | 
			
		||||
			if (gmsg.getSenderID() != sender.getID()) if (gmsg.getMemberStatuses().get(sender.getID()) == MessageStatus.READ) break;
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,8 @@ import java.time.Instant;
 | 
			
		||||
import java.util.*;
 | 
			
		||||
import java.util.logging.*;
 | 
			
		||||
 | 
			
		||||
import javafx.collections.*;
 | 
			
		||||
 | 
			
		||||
import envoy.client.event.EnvoyCloseEvent;
 | 
			
		||||
import envoy.data.*;
 | 
			
		||||
import envoy.data.Message.MessageStatus;
 | 
			
		||||
@@ -30,12 +32,12 @@ import dev.kske.eventbus.EventListener;
 | 
			
		||||
public final class LocalDB implements EventListener {
 | 
			
		||||
 | 
			
		||||
	// Data
 | 
			
		||||
	private User				user;
 | 
			
		||||
	private Map<String, User>	users		= Collections.synchronizedMap(new HashMap<>());
 | 
			
		||||
	private List<Chat>			chats		= Collections.synchronizedList(new ArrayList<>());
 | 
			
		||||
	private IDGenerator			idGenerator;
 | 
			
		||||
	private CacheMap			cacheMap	= new CacheMap();
 | 
			
		||||
	private String				authToken;
 | 
			
		||||
	private User					user;
 | 
			
		||||
	private Map<String, User>		users		= Collections.synchronizedMap(new HashMap<>());
 | 
			
		||||
	private ObservableList<Chat>	chats		= FXCollections.observableArrayList();
 | 
			
		||||
	private IDGenerator				idGenerator;
 | 
			
		||||
	private CacheMap				cacheMap	= new CacheMap();
 | 
			
		||||
	private String					authToken;
 | 
			
		||||
 | 
			
		||||
	// State management
 | 
			
		||||
	private Instant lastSync = Instant.EPOCH;
 | 
			
		||||
@@ -129,7 +131,7 @@ 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		= (List<Chat>) in.readObject();
 | 
			
		||||
			chats		= FXCollections.observableList((List<Chat>) in.readObject());
 | 
			
		||||
			cacheMap	= (CacheMap) in.readObject();
 | 
			
		||||
			lastSync	= (Instant) in.readObject();
 | 
			
		||||
		} finally {
 | 
			
		||||
@@ -189,8 +191,8 @@ public final class LocalDB implements EventListener {
 | 
			
		||||
			SerializationUtils.write(usersFile, users);
 | 
			
		||||
 | 
			
		||||
			// Save user data and last sync time stamp
 | 
			
		||||
			if (user != null)
 | 
			
		||||
				SerializationUtils.write(userFile, chats, cacheMap, Context.getInstance().getClient().isOnline() ? Instant.now() : lastSync);
 | 
			
		||||
			if (user != null) SerializationUtils
 | 
			
		||||
				.write(userFile, new ArrayList<>(chats), cacheMap, Context.getInstance().getClient().isOnline() ? Instant.now() : lastSync);
 | 
			
		||||
 | 
			
		||||
			// Save last login information
 | 
			
		||||
			if (authToken != null) SerializationUtils.write(lastLoginFile, user, authToken);
 | 
			
		||||
@@ -212,10 +214,12 @@ public final class LocalDB implements EventListener {
 | 
			
		||||
			logger.warning("The groupMessage has the unexpected status " + msg.getStatus());
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Event(priority = 150, includeSubtypes = true)
 | 
			
		||||
	private void onMessageStatusChange(MessageStatusChange evt) {
 | 
			
		||||
		// TODO: Cancel event once EventBus is updated
 | 
			
		||||
		if (evt.get().ordinal() < MessageStatus.RECEIVED.ordinal()) logger.warning("Received invalid " + evt);
 | 
			
		||||
	@Event(priority = 150)
 | 
			
		||||
	private void onMessageStatusChange(MessageStatusChange evt) { getMessage(evt.getID()).ifPresent(msg -> msg.setStatus(evt.get())); }
 | 
			
		||||
 | 
			
		||||
	@Event(priority = 150)
 | 
			
		||||
	private void onGroupMessageStatusChange(GroupMessageStatusChange evt) {
 | 
			
		||||
		this.<GroupMessage>getMessage(evt.getID()).ifPresent(msg -> msg.getMemberStatuses().replace(evt.getMemberID(), evt.get()));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Event(priority = 150)
 | 
			
		||||
@@ -249,8 +253,8 @@ public final class LocalDB implements EventListener {
 | 
			
		||||
	 * @return an optional containing the message
 | 
			
		||||
	 * @since Envoy Client v0.1-beta
 | 
			
		||||
	 */
 | 
			
		||||
	public Optional<Message> getMessage(long id) {
 | 
			
		||||
		return chats.stream().map(Chat::getMessages).flatMap(List::stream).filter(m -> m.getID() == id).findAny();
 | 
			
		||||
	public <T extends Message> Optional<T> getMessage(long id) {
 | 
			
		||||
		return (Optional<T>) chats.stream().map(Chat::getMessages).flatMap(List::stream).filter(m -> m.getID() == id).findAny();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -267,12 +271,7 @@ public final class LocalDB implements EventListener {
 | 
			
		||||
	 *         sender
 | 
			
		||||
	 * @since Envoy Client v0.1-alpha
 | 
			
		||||
	 **/
 | 
			
		||||
	public List<Chat> getChats() { return chats; }
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * @param chats the chats to set
 | 
			
		||||
	 */
 | 
			
		||||
	public void setChats(List<Chat> chats) { this.chats = chats; }
 | 
			
		||||
	public ObservableList<Chat> getChats() { return chats; }
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * @return the {@link User} who initialized the local database
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +0,0 @@
 | 
			
		||||
package envoy.client.ui;
 | 
			
		||||
 | 
			
		||||
import javafx.scene.control.*;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This is a utility class that provides access to a refreshing mechanism for
 | 
			
		||||
 * elements that were added without notifying the underlying {@link ListView}.
 | 
			
		||||
 *
 | 
			
		||||
 * @author Leon Hofmeister
 | 
			
		||||
 * @since Envoy Client v0.1-beta
 | 
			
		||||
 */
 | 
			
		||||
public final class ListViewRefresh {
 | 
			
		||||
 | 
			
		||||
	private ListViewRefresh() {}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Deeply refreshes a {@code listview}, meaning it recomputes every single of
 | 
			
		||||
	 * its {@link ListCell}s.
 | 
			
		||||
	 * <p>
 | 
			
		||||
	 * While it does work, it is <b>not the most efficient algorithm</b> possible.
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param toRefresh the listView to refresh
 | 
			
		||||
	 * @param <T>       the type of its {@code listcells}
 | 
			
		||||
	 * @since Envoy Client v0.1-beta
 | 
			
		||||
	 */
 | 
			
		||||
	public static <T> void deepRefresh(ListView<T> toRefresh) {
 | 
			
		||||
		final var items = toRefresh.getItems();
 | 
			
		||||
		toRefresh.setItems(null);
 | 
			
		||||
		toRefresh.setItems(items);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -11,7 +11,7 @@ import java.util.logging.*;
 | 
			
		||||
 | 
			
		||||
import javafx.animation.RotateTransition;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.collections.*;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.collections.transformation.FilteredList;
 | 
			
		||||
import javafx.fxml.*;
 | 
			
		||||
import javafx.scene.control.*;
 | 
			
		||||
@@ -167,7 +167,8 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
		messageList.setCellFactory(MessageListCell::new);
 | 
			
		||||
		chatList.setCellFactory(new ListCellFactory<>(ChatControl::new));
 | 
			
		||||
 | 
			
		||||
		// JavaFX provides an internal way of populating the context menu of a textarea.
 | 
			
		||||
		// 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)));
 | 
			
		||||
 | 
			
		||||
@@ -186,7 +187,7 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
		clip.setArcWidth(43);
 | 
			
		||||
		clientProfilePic.setClip(clip);
 | 
			
		||||
 | 
			
		||||
		chatList.setItems(chats = new FilteredList<>(FXCollections.observableList(localDB.getChats())));
 | 
			
		||||
		chatList.setItems(chats = new FilteredList<>(localDB.getChats()));
 | 
			
		||||
		contactLabel.setText(localDB.getUser().getName());
 | 
			
		||||
 | 
			
		||||
		initializeSystemCommandsMap();
 | 
			
		||||
@@ -228,12 +229,8 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
 | 
			
		||||
			// Read current chat or increment unread amount
 | 
			
		||||
			if (chat.equals(currentChat)) {
 | 
			
		||||
				try {
 | 
			
		||||
					currentChat.read(writeProxy);
 | 
			
		||||
				} catch (final IOException e) {
 | 
			
		||||
					logger.log(Level.WARNING, "Could not read current chat: ", e);
 | 
			
		||||
				}
 | 
			
		||||
				Platform.runLater(() -> { ListViewRefresh.deepRefresh(messageList); scrollToMessageListEnd(); });
 | 
			
		||||
				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
 | 
			
		||||
@@ -248,22 +245,12 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
 | 
			
		||||
	@Event
 | 
			
		||||
	private void onMessageStatusChange(MessageStatusChange evt) {
 | 
			
		||||
		localDB.getMessage(evt.getID()).ifPresent(message -> {
 | 
			
		||||
			message.setStatus(evt.get());
 | 
			
		||||
			// Update UI if in current chat and the current user was the sender of the
 | 
			
		||||
			// message
 | 
			
		||||
			if (currentChat != null && message.getSenderID() == client.getSender().getID()) Platform.runLater(messageList::refresh);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Event
 | 
			
		||||
	private void onGroupMessageStatusChange(GroupMessageStatusChange evt) {
 | 
			
		||||
		localDB.getMessage(evt.getID()).ifPresent(groupMessage -> {
 | 
			
		||||
			((GroupMessage) groupMessage).getMemberStatuses().replace(evt.getMemberID(), evt.get());
 | 
			
		||||
 | 
			
		||||
			// Update UI if in current chat
 | 
			
		||||
			if (currentChat != null && groupMessage.getRecipientID() == currentChat.getRecipient().getID()) Platform.runLater(messageList::refresh);
 | 
			
		||||
		});
 | 
			
		||||
		// 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
 | 
			
		||||
@@ -273,7 +260,7 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
			.filter(c -> c.getRecipient().getID() == evt.getID())
 | 
			
		||||
			.findAny()
 | 
			
		||||
			.map(Chat::getRecipient)
 | 
			
		||||
			.ifPresent(u -> { ((User) u).setStatus(evt.get()); Platform.runLater(() -> ListViewRefresh.deepRefresh(chatList)); });
 | 
			
		||||
			.ifPresent(u -> ((User) u).setStatus(evt.get()));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	@Event
 | 
			
		||||
@@ -318,6 +305,7 @@ 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.getRecipient() instanceof User) recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("user_icon", 43));
 | 
			
		||||
		else recipientProfilePic.setImage(IconUtil.loadIconThemeSensitive("group_icon", 43));
 | 
			
		||||
	}
 | 
			
		||||
@@ -358,18 +346,14 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
			// Load the chat
 | 
			
		||||
			currentChat = localDB.getChat(user.getID()).get();
 | 
			
		||||
 | 
			
		||||
			messageList.setItems(FXCollections.observableList(currentChat.getMessages()));
 | 
			
		||||
			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
 | 
			
		||||
			try {
 | 
			
		||||
				currentChat.read(writeProxy);
 | 
			
		||||
			} catch (final IOException e) {
 | 
			
		||||
				logger.log(Level.WARNING, "Could not read current chat.", e);
 | 
			
		||||
			}
 | 
			
		||||
			currentChat.read(writeProxy);
 | 
			
		||||
 | 
			
		||||
			// Discard the pending attachment
 | 
			
		||||
			if (recorder.isRecording()) {
 | 
			
		||||
@@ -690,7 +674,6 @@ public final class ChatScene implements EventListener, Restorable {
 | 
			
		||||
				localDB.getChats().remove(currentChat);
 | 
			
		||||
				localDB.getChats().add(0, currentChat);
 | 
			
		||||
			});
 | 
			
		||||
			ListViewRefresh.deepRefresh(messageList);
 | 
			
		||||
			scrollToMessageListEnd();
 | 
			
		||||
 | 
			
		||||
			// Request a new ID generator if all IDs were used
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user