diff --git a/client/pom.xml b/client/pom.xml index 7e85b4d..62de01d 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -21,7 +21,7 @@ com.googlecode.lanterna lanterna - 3.1.1 + 3.2.0-alpha1 \ No newline at end of file diff --git a/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java b/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java deleted file mode 100644 index bac835a..0000000 --- a/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package nl.andrewl.concord_client; - -import nl.andrewl.concord_core.msg.Message; - -import java.io.IOException; - -public interface ClientMessageListener { - void messageReceived(ConcordClient client, Message message) throws IOException; -} diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java index 5e5db90..cdbe102 100644 --- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -8,8 +8,12 @@ import com.googlecode.lanterna.screen.TerminalScreen; import com.googlecode.lanterna.terminal.DefaultTerminalFactory; import com.googlecode.lanterna.terminal.Terminal; import lombok.Getter; -import lombok.Setter; +import nl.andrewl.concord_client.event.EventManager; +import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler; +import nl.andrewl.concord_client.event.handlers.ChannelUsersResponseHandler; +import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler; import nl.andrewl.concord_client.gui.MainWindow; +import nl.andrewl.concord_client.model.ClientModel; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.types.*; @@ -18,52 +22,41 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.net.Socket; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; public class ConcordClient implements Runnable { private final Socket socket; private final DataInputStream in; private final DataOutputStream out; - private final UUID id; - private final String nickname; + @Getter - @Setter - private UUID currentChannelId; - @Getter - private ServerMetaData serverMetaData; - private final Set messageListeners; + private final ClientModel model; + + private final EventManager eventManager; + private volatile boolean running; public ConcordClient(String host, int port, String nickname) throws IOException { + this.eventManager = new EventManager(this); this.socket = new Socket(host, port); this.in = new DataInputStream(this.socket.getInputStream()); this.out = new DataOutputStream(this.socket.getOutputStream()); - this.nickname = nickname; Serializer.writeMessage(new Identification(nickname), this.out); Message reply = Serializer.readMessage(this.in); if (reply instanceof ServerWelcome welcome) { - this.id = welcome.getClientId(); - this.currentChannelId = welcome.getCurrentChannelId(); - this.serverMetaData = welcome.getMetaData(); - + this.model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getMetaData()); // Start fetching initial data for the channel we were initially put into. - this.sendMessage(new ChannelUsersRequest(this.currentChannelId)); - this.sendMessage(new ChatHistoryRequest(this.currentChannelId, ChatHistoryRequest.Source.CHANNEL, "")); + this.sendMessage(new ChannelUsersRequest(this.model.getCurrentChannelId())); + this.sendMessage(new ChatHistoryRequest(this.model.getCurrentChannelId(), "")); } else { throw new IOException("Unexpected response from the server after sending identification message."); } - this.messageListeners = new HashSet<>(); - } - public void addListener(ClientMessageListener listener) { - this.messageListeners.add(listener); - } - - public void removeListener(ClientMessageListener listener) { - this.messageListeners.remove(listener); + // Add event listeners. + this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler()); + this.eventManager.addHandler(ChannelUsersResponse.class, new ChannelUsersResponseHandler()); + this.eventManager.addHandler(ChatHistoryResponse.class, new ChatHistoryResponseHandler()); + this.eventManager.addHandler(Chat.class, (msg, client) -> client.getModel().getChatHistory().addChat(msg)); } public void sendMessage(Message message) throws IOException { @@ -71,7 +64,7 @@ public class ConcordClient implements Runnable { } public void sendChat(String message) throws IOException { - Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out); + Serializer.writeMessage(new Chat(this.model.getId(), this.model.getNickname(), System.currentTimeMillis(), message), this.out); } public void shutdown() { @@ -91,9 +84,7 @@ public class ConcordClient implements Runnable { while (this.running) { try { Message msg = Serializer.readMessage(this.in); - for (var listener : this.messageListeners) { - listener.messageReceived(this, msg); - } + this.eventManager.handle(msg); } catch (IOException e) { e.printStackTrace(); this.running = false; diff --git a/client/src/main/java/nl/andrewl/concord_client/event/ChatHistoryListener.java b/client/src/main/java/nl/andrewl/concord_client/event/ChatHistoryListener.java new file mode 100644 index 0000000..dd78493 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/ChatHistoryListener.java @@ -0,0 +1,12 @@ +package nl.andrewl.concord_client.event; + +import nl.andrewl.concord_client.model.ChatHistory; +import nl.andrewl.concord_core.msg.types.Chat; + +public interface ChatHistoryListener { + default void chatAdded(Chat chat) {} + + default void chatRemoved(Chat chat) {} + + default void chatUpdated(ChatHistory history) {} +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java b/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java new file mode 100644 index 0000000..469ff35 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java @@ -0,0 +1,12 @@ +package nl.andrewl.concord_client.event; + +import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; + +import java.util.List; +import java.util.UUID; + +public interface ClientModelListener { + default void channelMoved(UUID oldChannelId, UUID newChannelId) {} + + default void usersUpdated(List users) {} +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/EventManager.java b/client/src/main/java/nl/andrewl/concord_client/event/EventManager.java new file mode 100644 index 0000000..b73caf1 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/EventManager.java @@ -0,0 +1,38 @@ +package nl.andrewl.concord_client.event; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_core.msg.Message; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +public class EventManager { + private final Map, List>> messageHandlers; + private final ConcordClient client; + + public EventManager(ConcordClient client) { + this.client = client; + this.messageHandlers = new ConcurrentHashMap<>(); + } + + public void addHandler(Class messageClass, MessageHandler handler) { + var handlers = this.messageHandlers.computeIfAbsent(messageClass, k -> new CopyOnWriteArrayList<>()); + handlers.add(handler); + } + + @SuppressWarnings("unchecked") + public void handle(T message) { + var handlers = this.messageHandlers.get(message.getClass()); + if (handlers != null) { + for (var handler : handlers) { + try { + ((MessageHandler) handler).handle(message, this.client); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/MessageHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/MessageHandler.java new file mode 100644 index 0000000..f3c8fbf --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/MessageHandler.java @@ -0,0 +1,8 @@ +package nl.andrewl.concord_client.event; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_core.msg.Message; + +public interface MessageHandler { + void handle(T msg, ConcordClient client) throws Exception; +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java new file mode 100644 index 0000000..e59af02 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java @@ -0,0 +1,16 @@ +package nl.andrewl.concord_client.event.handlers; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_client.event.MessageHandler; +import nl.andrewl.concord_core.msg.types.ChannelUsersRequest; +import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; +import nl.andrewl.concord_core.msg.types.MoveToChannel; + +public class ChannelMovedHandler implements MessageHandler { + @Override + public void handle(MoveToChannel msg, ConcordClient client) throws Exception { + client.getModel().setCurrentChannelId(msg.getChannelId()); + client.sendMessage(new ChatHistoryRequest(msg.getChannelId(), "")); + client.sendMessage(new ChannelUsersRequest(msg.getChannelId())); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelUsersResponseHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelUsersResponseHandler.java new file mode 100644 index 0000000..ef79234 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelUsersResponseHandler.java @@ -0,0 +1,12 @@ +package nl.andrewl.concord_client.event.handlers; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_client.event.MessageHandler; +import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; + +public class ChannelUsersResponseHandler implements MessageHandler { + @Override + public void handle(ChannelUsersResponse msg, ConcordClient client) throws Exception { + client.getModel().setKnownUsers(msg.getUsers()); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java new file mode 100644 index 0000000..77866f5 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java @@ -0,0 +1,12 @@ +package nl.andrewl.concord_client.event.handlers; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_client.event.MessageHandler; +import nl.andrewl.concord_core.msg.types.ChatHistoryResponse; + +public class ChatHistoryResponseHandler implements MessageHandler { + @Override + public void handle(ChatHistoryResponse msg, ConcordClient client) throws Exception { + client.getModel().getChatHistory().setChats(msg.getMessages()); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java index 359804c..1ad29ae 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java @@ -26,6 +26,7 @@ public class ChannelChatBox extends Panel { super(new BorderLayout()); this.client = client; this.chatList = new ChatList(); + this.client.getModel().getChatHistory().addListener(this.chatList); this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); this.inputTextBox.setCaretWarp(true); this.inputTextBox.setPreferredSize(new TerminalSize(0, 3)); @@ -37,7 +38,6 @@ public class ChannelChatBox extends Panel { String text = inputTextBox.getText(); if (text != null && !text.isBlank()) { try { - System.out.println("Sending: " + text.trim()); client.sendChat(text.trim()); inputTextBox.setText(""); } catch (IOException e) { @@ -53,8 +53,8 @@ public class ChannelChatBox extends Panel { } public void refreshBorder() { - String name = client.getServerMetaData().getChannels().stream() - .filter(channelData -> channelData.getId().equals(client.getCurrentChannelId())) + String name = client.getModel().getServerMetaData().getChannels().stream() + .filter(channelData -> channelData.getId().equals(client.getModel().getCurrentChannelId())) .findAny().orElseThrow().getName(); if (this.chatBorder != null) this.removeComponent(this.chatBorder); this.chatBorder = Borders.doubleLine("#" + name); diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java index aad5eb6..e883888 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java @@ -6,6 +6,10 @@ import nl.andrewl.concord_core.msg.types.MoveToChannel; import java.io.IOException; +/** + * Panel that contains a list of channels. A user can interact with a channel to + * move to that channel. The current channel is indicated via a "*". + */ public class ChannelList extends Panel { private final ConcordClient client; public ChannelList(ConcordClient client) { @@ -15,13 +19,13 @@ public class ChannelList extends Panel { public void setChannels() { this.removeAllComponents(); - for (var channel : this.client.getServerMetaData().getChannels()) { + for (var channel : this.client.getModel().getServerMetaData().getChannels()) { String name = channel.getName(); - if (client.getCurrentChannelId().equals(channel.getId())) { + if (client.getModel().getCurrentChannelId().equals(channel.getId())) { name = "*" + name; } Button b = new Button(name, () -> { - if (!client.getCurrentChannelId().equals(channel.getId())) { + if (!client.getModel().getCurrentChannelId().equals(channel.getId())) { try { client.sendMessage(new MoveToChannel(channel.getId())); } catch (IOException e) { diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java index a02831a..5a5f644 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java @@ -1,9 +1,15 @@ package nl.andrewl.concord_client.gui; import com.googlecode.lanterna.gui2.AbstractListBox; +import nl.andrewl.concord_client.event.ChatHistoryListener; +import nl.andrewl.concord_client.model.ChatHistory; import nl.andrewl.concord_core.msg.types.Chat; -public class ChatList extends AbstractListBox { +/** + * This chat list shows a section of chat messages that have been sent in a + * single channel (server channel, thread, or direct message). + */ +public class ChatList extends AbstractListBox implements ChatHistoryListener { /** * Adds one more item to the list box, at the end. * @@ -28,4 +34,31 @@ public class ChatList extends AbstractListBox { protected ListItemRenderer createDefaultListItemRenderer() { return new ChatRenderer(); } + + @Override + public void chatAdded(Chat chat) { + this.getTextGUI().getGUIThread().invokeLater(() -> { + this.addItem(chat); + }); + } + + @Override + public void chatRemoved(Chat chat) { + for (int i = 0; i < this.getItemCount(); i++) { + if (this.getItemAt(i).equals(chat)) { + this.removeItem(i); + return; + } + } + } + + @Override + public void chatUpdated(ChatHistory history) { + this.getTextGUI().getGUIThread().invokeLater(() -> { + this.clearItems(); + for (var chat : history.getChats()) { + this.addItem(chat); + } + }); + } } diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java index 7832f07..71477b6 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java @@ -52,7 +52,7 @@ public class MainWindow extends BasicWindow { try { var client = new ConcordClient(host, port, nickname); var chatPanel = new ServerPanel(client, this); - client.addListener(chatPanel); + client.getModel().addListener(chatPanel); new Thread(client).start(); this.setComponent(chatPanel); } catch (IOException e) { diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java b/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java index 3b49dde..9c57e44 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java @@ -2,12 +2,12 @@ package nl.andrewl.concord_client.gui; import com.googlecode.lanterna.gui2.*; import lombok.Getter; -import nl.andrewl.concord_client.ClientMessageListener; import nl.andrewl.concord_client.ConcordClient; -import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.*; +import nl.andrewl.concord_client.event.ClientModelListener; +import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; -import java.io.IOException; +import java.util.List; +import java.util.UUID; /** * The main panel in which a user interacts with the application during normal @@ -16,19 +16,17 @@ import java.io.IOException; * meta information in the sidebars which provides the user with a list of all * threads and users in the server. */ -public class ServerPanel extends Panel implements ClientMessageListener { +public class ServerPanel extends Panel implements ClientModelListener { @Getter private final ChannelChatBox channelChatBox; private final ChannelList channelList; private final UserList userList; - private final ConcordClient client; private final TextGUIThread guiThread; public ServerPanel(ConcordClient client, Window window) { super(new BorderLayout()); this.guiThread = window.getTextGUI().getGUIThread(); - this.client = client; this.channelChatBox = new ChannelChatBox(client, window); this.channelList = new ChannelList(client); this.channelList.setChannels(); @@ -47,36 +45,19 @@ public class ServerPanel extends Panel implements ClientMessageListener { } @Override - public void messageReceived(ConcordClient client, Message message) { - if (message instanceof Chat chat) { - this.channelChatBox.getChatList().addItem(chat); - } else if (message instanceof MoveToChannel moveToChannel) { - client.setCurrentChannelId(moveToChannel.getChannelId()); - try { - client.sendMessage(new ChatHistoryRequest(moveToChannel.getChannelId(), ChatHistoryRequest.Source.CHANNEL, "")); - client.sendMessage(new ChannelUsersRequest(moveToChannel.getChannelId())); - } catch (IOException e) { - e.printStackTrace(); - } - this.guiThread.invokeLater(() -> { - this.channelList.setChannels(); - this.channelChatBox.getChatList().clearItems(); - this.channelChatBox.refreshBorder(); - this.channelChatBox.getInputTextBox().takeFocus(); - }); - } else if (message instanceof ChannelUsersResponse channelUsersResponse) { - this.guiThread.invokeLater(() -> { - this.userList.updateUsers(channelUsersResponse); - }); - } else if (message instanceof ChatHistoryResponse chatHistoryResponse) { - System.out.println("Got chat history response: " + chatHistoryResponse.getSourceId()); - System.out.println(chatHistoryResponse.getMessages()); - this.guiThread.invokeLater(() -> { - this.channelChatBox.getChatList().clearItems(); - for (var chat : chatHistoryResponse.getMessages()) { - this.channelChatBox.getChatList().addItem(chat); - } - }); - } + public void channelMoved(UUID oldChannelId, UUID newChannelId) { + this.getTextGUI().getGUIThread().invokeLater(() -> { + this.channelList.setChannels(); + this.channelChatBox.getChatList().clearItems(); + this.channelChatBox.refreshBorder(); + this.channelChatBox.getInputTextBox().takeFocus(); + }); + } + + @Override + public void usersUpdated(List users) { + this.guiThread.invokeLater(() -> { + this.userList.updateUsers(users); + }); } } diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java b/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java index be92b30..ef99a1b 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java @@ -7,6 +7,8 @@ import com.googlecode.lanterna.gui2.Panel; import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; +import java.util.List; + public class UserList extends Panel { private final ConcordClient client; @@ -15,9 +17,9 @@ public class UserList extends Panel { this.client = client; } - public void updateUsers(ChannelUsersResponse usersResponse) { + public void updateUsers(List usersResponse) { this.removeAllComponents(); - for (var user : usersResponse.getUsers()) { + for (var user : usersResponse) { Button b = new Button(user.getName(), () -> { System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId()); }); diff --git a/client/src/main/java/nl/andrewl/concord_client/model/ChatHistory.java b/client/src/main/java/nl/andrewl/concord_client/model/ChatHistory.java new file mode 100644 index 0000000..2014f43 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/model/ChatHistory.java @@ -0,0 +1,44 @@ +package nl.andrewl.concord_client.model; + +import lombok.Getter; +import nl.andrewl.concord_client.event.ChatHistoryListener; +import nl.andrewl.concord_core.msg.types.Chat; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Stores information about a snippet of chat history that the client is + * currently viewing. This might be some older section of chats, or it could be + * the currently-in-use channel chats. + */ +public class ChatHistory { + @Getter + private List chats; + + private final List chatHistoryListeners; + + public ChatHistory() { + this.chats = new CopyOnWriteArrayList<>(); + this.chatHistoryListeners = new CopyOnWriteArrayList<>(); + } + + public void setChats(List chats) { + this.chats.clear(); + this.chats.addAll(chats); + this.chatHistoryListeners.forEach(listener -> listener.chatUpdated(this)); + } + + public void addChat(Chat chat) { + this.chats.add(chat); + this.chatHistoryListeners.forEach(listener -> listener.chatAdded(chat)); + } + + public void addListener(ChatHistoryListener listener) { + this.chatHistoryListeners.add(listener); + } + + public void removeListener(ChatHistoryListener listener) { + this.chatHistoryListeners.remove(listener); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java new file mode 100644 index 0000000..3a44273 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java @@ -0,0 +1,53 @@ +package nl.andrewl.concord_client.model; + +import lombok.Getter; +import nl.andrewl.concord_client.event.ClientModelListener; +import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; +import nl.andrewl.concord_core.msg.types.ServerMetaData; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +@Getter +public class ClientModel { + private UUID id; + private String nickname; + private ServerMetaData serverMetaData; + + private UUID currentChannelId; + private List knownUsers; + private final ChatHistory chatHistory; + + private final List modelListeners; + + public ClientModel(UUID id, String nickname, UUID currentChannelId, ServerMetaData serverMetaData) { + this.modelListeners = new CopyOnWriteArrayList<>(); + this.id = id; + this.nickname = nickname; + this.currentChannelId = currentChannelId; + this.serverMetaData = serverMetaData; + this.knownUsers = new ArrayList<>(); + this.chatHistory = new ChatHistory(); + } + + public void setCurrentChannelId(UUID newChannelId) { + UUID oldId = this.currentChannelId; + this.currentChannelId = newChannelId; + this.modelListeners.forEach(listener -> listener.channelMoved(oldId, newChannelId)); + } + + public void setKnownUsers(List users) { + this.knownUsers = users; + this.modelListeners.forEach(listener -> listener.usersUpdated(this.knownUsers)); + } + + public void addListener(ClientModelListener listener) { + this.modelListeners.add(listener); + } + + public void removeListener(ClientModelListener listener) { + this.modelListeners.remove(listener); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java index ec536e5..16e8253 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java @@ -58,4 +58,16 @@ public class Chat implements Message { public String toString() { return String.format("%s: %s", this.senderNickname, this.message); } + + @Override + public boolean equals(Object o) { + if (o.getClass().equals(this.getClass())) { + Chat other = (Chat) o; + return this.getSenderId().equals(other.getSenderId()) && + this.getTimestamp() == other.getTimestamp() && + this.getSenderNickname().equals(other.getSenderNickname()) && + this.message.length() == other.message.length(); + } + return false; + } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java index 07a8894..a1cc69e 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java @@ -8,7 +8,10 @@ import nl.andrewl.concord_core.msg.Message; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static nl.andrewl.concord_core.msg.MessageUtils.*; @@ -49,12 +52,36 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; @NoArgsConstructor @AllArgsConstructor public class ChatHistoryRequest implements Message { - public enum Source {CHANNEL, THREAD, DIRECT_MESSAGE} - - private UUID sourceId; - private Source sourceType; + private UUID channelId; private String query; + public ChatHistoryRequest(UUID channelId, Map params) { + this.channelId = channelId; + this.query = params.entrySet().stream() + .map(entry -> { + if (entry.getKey().contains(";") || entry.getKey().contains("=")) { + throw new IllegalArgumentException("Parameter key \"" + entry.getKey() + "\" contains invalid characters."); + } + if (entry.getValue().contains(";") || entry.getValue().contains("=")) { + throw new IllegalArgumentException("Parameter value \"" + entry.getValue() + "\" contains invalid characters."); + } + return entry.getKey() + "=" + entry.getValue(); + }) + .collect(Collectors.joining(";")); + } + + public Map getQueryAsMap() { + String[] pairs = this.query.split(";"); + if (pairs.length == 0) return Map.of(); + Map params = new HashMap<>(pairs.length); + for (var pair : pairs) { + String[] keyAndValue = pair.split("="); + if (keyAndValue.length != 2) continue; + params.put(keyAndValue[0], keyAndValue[1]); + } + return params; + } + @Override public int getByteCount() { return UUID_BYTES + Integer.BYTES + getByteSize(this.query); @@ -62,15 +89,13 @@ public class ChatHistoryRequest implements Message { @Override public void write(DataOutputStream o) throws IOException { - writeUUID(this.sourceId, o); - writeEnum(this.sourceType, o); + writeUUID(this.channelId, o); writeString(this.query, o); } @Override public void read(DataInputStream i) throws IOException { - this.sourceId = readUUID(i); - this.sourceType = readEnum(Source.class, i); + this.channelId = readUUID(i); this.query = readString(i); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java index 7f74e12..bc2ebec 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java @@ -20,13 +20,12 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor public class ChatHistoryResponse implements Message { - private UUID sourceId; - private ChatHistoryRequest.Source sourceType; + private UUID channelId; List messages; @Override public int getByteCount() { - int count = Long.BYTES + Integer.BYTES + Integer.BYTES; + int count = Long.BYTES + Integer.BYTES; for (var message : this.messages) { count += message.getByteCount(); } @@ -35,8 +34,7 @@ public class ChatHistoryResponse implements Message { @Override public void write(DataOutputStream o) throws IOException { - MessageUtils.writeUUID(this.sourceId, o); - MessageUtils.writeEnum(this.sourceType, o); + MessageUtils.writeUUID(this.channelId, o); o.writeInt(messages.size()); for (var message : this.messages) { message.write(o); @@ -45,8 +43,7 @@ public class ChatHistoryResponse implements Message { @Override public void read(DataInputStream i) throws IOException { - this.sourceId = MessageUtils.readUUID(i); - this.sourceType = MessageUtils.readEnum(ChatHistoryRequest.Source.class, i); + this.channelId = MessageUtils.readUUID(i); int messageCount = i.readInt(); Chat[] messages = new Chat[messageCount]; for (int k = 0; k < messageCount; k++) { diff --git a/server/src/main/java/nl/andrewl/concord_server/Channel.java b/server/src/main/java/nl/andrewl/concord_server/Channel.java index 642c5bd..96eecfc 100644 --- a/server/src/main/java/nl/andrewl/concord_server/Channel.java +++ b/server/src/main/java/nl/andrewl/concord_server/Channel.java @@ -89,6 +89,6 @@ public class Channel { @Override public String toString() { - return this.name; + return this.name + " (" + this.id + ")"; } } diff --git a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java index c38d20e..f07e714 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java @@ -49,6 +49,6 @@ public class ChannelManager { channel.addClient(client); client.setCurrentChannel(channel); client.sendToClient(new MoveToChannel(channel.getId())); - System.out.println("Moved client " + client.getClientNickname() + " to channel " + channel.getName()); + System.out.println("Moved client " + client + " to channel " + channel); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java index dea8a95..19f7595 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java +++ b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java @@ -2,7 +2,6 @@ package nl.andrewl.concord_server; import lombok.Getter; import lombok.Setter; -import lombok.extern.java.Log; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.types.Identification; @@ -17,7 +16,6 @@ import java.util.UUID; * This thread is responsible for handling the connection to a single client of * a server. The client thread acts as the server's representation of a client. */ -@Log public class ClientThread extends Thread { private final Socket socket; private final DataInputStream in; @@ -75,7 +73,7 @@ public class ClientThread extends Thread { public void run() { this.running = true; if (!identifyClient()) { - log.warning("Could not identify the client; aborting connection."); + System.err.println("Could not identify the client; aborting connection."); this.running = false; } @@ -84,7 +82,6 @@ public class ClientThread extends Thread { var msg = Serializer.readMessage(this.in); this.server.getEventManager().handle(msg, this); } catch (IOException e) { - log.info("Client disconnected: " + e.getMessage()); this.running = false; } } @@ -124,4 +121,9 @@ public class ClientThread extends Thread { } return false; } + + @Override + public String toString() { + return this.clientNickname + " (" + this.clientId + ")"; + } } diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index 5e531b3..05bef83 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -22,16 +22,19 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.stream.Collectors; -@Log +/** + * The main server implementation, which handles accepting new clients. + */ public class ConcordServer implements Runnable { private final Map clients; - private final int port; - private final String name; + private volatile boolean running; + + @Getter + private final ServerConfig config; @Getter private final IdProvider idProvider; @Getter private final Nitrite db; - private volatile boolean running; @Getter private final ExecutorService executorService; @Getter @@ -41,9 +44,7 @@ public class ConcordServer implements Runnable { public ConcordServer() { this.idProvider = new UUIDProvider(); - ServerConfig config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); - this.port = config.port(); - this.name = config.name(); + this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); this.db = Nitrite.builder() .filePath("concord-server.db") .openOrCreate(); @@ -67,15 +68,15 @@ public class ConcordServer implements Runnable { for (var channel : this.channelManager.getChannels()) { var col = channel.getMessageCollection(); if (!col.hasIndex("timestamp")) { - log.info("Adding timestamp index to collection for channel " + channel.getName()); + System.out.println("Adding timestamp index to collection for channel " + channel.getName()); col.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique)); } if (!col.hasIndex("senderNickname")) { - log.info("Adding senderNickname index to collection for channel " + channel.getName()); + System.out.println("Adding senderNickname index to collection for channel " + channel.getName()); col.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext)); } if (!col.hasIndex("message")) { - log.info("Adding message index to collection for channel " + channel.getName()); + System.out.println("Adding message index to collection for channel " + channel.getName()); col.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext)); } } @@ -92,13 +93,13 @@ public class ConcordServer implements Runnable { */ public void registerClient(Identification identification, ClientThread clientThread) { var id = this.idProvider.newId(); - log.info("Registering new client " + identification.getNickname() + " with id " + id); + System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id); this.clients.put(id, clientThread); clientThread.setClientId(id); clientThread.setClientNickname(identification.getNickname()); // Send a welcome reply containing all the initial server info the client needs. ServerMetaData metaData = new ServerMetaData( - this.name, + this.config.name(), this.channelManager.getChannels().stream() .map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName())) .sorted(Comparator.comparing(ServerMetaData.ChannelData::getName)) @@ -110,6 +111,7 @@ public class ConcordServer implements Runnable { // It is important that we send the welcome message first. The client expects this as the initial response to their identification message. defaultChannel.addClient(clientThread); clientThread.setCurrentChannel(defaultChannel); + System.out.println("Moved client " + clientThread + " to " + defaultChannel); } /** @@ -122,6 +124,7 @@ public class ConcordServer implements Runnable { if (client != null) { client.getCurrentChannel().removeClient(client); client.shutdown(); + System.out.println("Client " + client + " has disconnected."); } } @@ -130,11 +133,15 @@ public class ConcordServer implements Runnable { this.running = true; ServerSocket serverSocket; try { - serverSocket = new ServerSocket(this.port); - log.info("Opened server on port " + this.port); + serverSocket = new ServerSocket(this.config.port()); + StringBuilder startupMessage = new StringBuilder(); + startupMessage.append("Opened server on port ").append(config.port()).append("\n"); + for (var channel : this.channelManager.getChannels()) { + startupMessage.append("\tChannel \"").append(channel).append('\n'); + } + System.out.println(startupMessage); while (this.running) { Socket socket = serverSocket.accept(); - log.info("Accepted new socket connection from " + socket.getInetAddress().getHostAddress()); ClientThread clientThread = new ClientThread(socket, this); clientThread.start(); } diff --git a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java index b98fc3a..dd2680a 100644 --- a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java +++ b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java @@ -9,10 +9,15 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -@Log public record ServerConfig( String name, int port, + + // Global Channel configuration + int chatHistoryMaxCount, + int chatHistoryDefaultCount, + int maxMessageLength, + ChannelConfig[] channels ) { @@ -29,6 +34,9 @@ public record ServerConfig( config = new ServerConfig( "My Concord Server", 8123, + 100, + 50, + 8192, new ServerConfig.ChannelConfig[]{ new ServerConfig.ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.") } @@ -38,14 +46,14 @@ public record ServerConfig( } catch (IOException e) { throw new UncheckedIOException(e); } - log.info(filePath + " does not exist. Creating it with initial values. Edit and restart to apply changes."); + System.err.println(filePath + " does not exist. Creating it with initial values. Edit and restart to apply changes."); } else { try { config = mapper.readValue(Files.newInputStream(filePath), ServerConfig.class); } catch (IOException e) { throw new UncheckedIOException(e); } - log.info("Loaded configuration from " + filePath); + System.out.println("Loaded configuration from " + filePath); } return config; } diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java index 70dc421..e553ee3 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java @@ -3,41 +3,72 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.ChatHistoryResponse; +import nl.andrewl.concord_server.Channel; import nl.andrewl.concord_server.ClientThread; import nl.andrewl.concord_server.ConcordServer; -import org.dizitart.no2.Document; -import org.dizitart.no2.FindOptions; -import org.dizitart.no2.SortOrder; +import org.dizitart.no2.*; +import org.dizitart.no2.filters.Filters; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.UUID; +import java.util.*; +/** + * Handles client requests for sections of chat history for a particular channel. + */ public class ChatHistoryRequestHandler implements MessageHandler { @Override public void handle(ChatHistoryRequest msg, ClientThread client, ConcordServer server) { - var optionalChannel = server.getChannelManager().getChannelById(msg.getSourceId()); + var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId()); if (optionalChannel.isPresent()) { var channel = optionalChannel.get(); - System.out.println("Looking for chats in channel-" + channel.getId()); - var col = server.getDb().getCollection("channel-" + channel.getId()); - var cursor = col.find( - FindOptions.sort("timestamp", SortOrder.Descending) - .thenLimit(0, 10) - ); - List chats = new ArrayList<>(10); - for (Document doc : cursor) { - chats.add(new Chat( - doc.get("senderId", UUID.class), - doc.get("senderNickname", String.class), - doc.get("timestamp", Long.class), - doc.get("message", String.class) - )); + var params = msg.getQueryAsMap(); + Long count = this.getOrDefault(params, "count", (long) server.getConfig().chatHistoryDefaultCount()); + if (count > server.getConfig().chatHistoryMaxCount()) { + return; } - col.close(); - chats.sort(Comparator.comparingLong(Chat::getTimestamp)); - client.sendToClient(new ChatHistoryResponse(msg.getSourceId(), msg.getSourceType(), chats)); + Long from = this.getOrDefault(params, "from", null); + Long to = this.getOrDefault(params, "to", null); + client.sendToClient(this.getResponse(channel, count, from, to)); } } + + private Long getOrDefault(Map params, String key, Long defaultValue) { + String value = params.get(key); + if (value == null) return defaultValue; + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private ChatHistoryResponse getResponse(Channel channel, long count, Long from, Long to) { + var col = channel.getServer().getDb().getCollection("channel-" + channel.getId()); + Cursor cursor; + FindOptions options = FindOptions.sort("timestamp", SortOrder.Descending).thenLimit(0, (int) count); + List filters = new ArrayList<>(2); + if (from != null) { + filters.add(Filters.gt("timestamp", from)); + } + if (to != null) { + filters.add(Filters.lt("timestamp", to)); + } + if (filters.isEmpty()) { + cursor = col.find(options); + } else { + cursor = col.find(Filters.and(filters.toArray(new Filter[0])), options); + } + + List chats = new ArrayList<>((int) count); + for (Document doc : cursor) { + chats.add(new Chat( + doc.get("senderId", UUID.class), + doc.get("senderNickname", String.class), + doc.get("timestamp", Long.class), + doc.get("message", String.class) + )); + } + col.close(); + chats.sort(Comparator.comparingLong(Chat::getTimestamp)); + return new ChatHistoryResponse(channel.getId(), chats); + } } diff --git a/server/src/main/resources/nl/andrewl/concord_server/logback.xml b/server/src/main/resources/nl/andrewl/concord_server/logback.xml deleted file mode 100644 index 57779e4..0000000 --- a/server/src/main/resources/nl/andrewl/concord_server/logback.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - %d{dd.MM.yyyy HH:mm:ss} %boldCyan(%-34.-34thread) %red(%10.10X{jda.shard}) %boldGreen(%-15.-15logger{0}) %highlight(%-6level) %msg%n - - - - - - -