Typing indicator + Metrics

This commit is contained in:
Donatas Kirda 2023-12-24 02:46:27 +02:00
parent ac58009119
commit a477a87c3d
Signed by: bloodwiing
GPG Key ID: 63020D8D3F4A164F
20 changed files with 1201 additions and 316 deletions

View File

@ -2,12 +2,15 @@ package dev.wiing.gossip.client;
import dev.wiing.gossip.lib.PacketHandler; import dev.wiing.gossip.lib.PacketHandler;
import dev.wiing.gossip.lib.PacketManager; import dev.wiing.gossip.lib.PacketManager;
import dev.wiing.gossip.lib.PacketMetrics;
import dev.wiing.gossip.lib.data.LongData; import dev.wiing.gossip.lib.data.LongData;
import dev.wiing.gossip.lib.models.SecretUser; import dev.wiing.gossip.lib.models.SecretUser;
import dev.wiing.gossip.lib.models.Topic; import dev.wiing.gossip.lib.models.Topic;
import dev.wiing.gossip.lib.models.User; import dev.wiing.gossip.lib.models.User;
import dev.wiing.gossip.lib.packets.*; import dev.wiing.gossip.lib.packets.*;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.net.SocketException; import java.net.SocketException;
@ -18,6 +21,13 @@ public class Connection {
private static final Connection instance = new Connection(); private static final Connection instance = new Connection();
private PropertyChangeSupport changeSupport;
private boolean recordStats = false;
private String compiledConnectionStats = "CONNECTION STATS\nNeed an update";
private String compiledCacheStats = "CACHE STATS\nNeed an update";
private final PacketManager packetManager = new PacketManager(); private final PacketManager packetManager = new PacketManager();
private final PacketHandler packetHandler = new PacketHandler(); private final PacketHandler packetHandler = new PacketHandler();
@ -32,6 +42,7 @@ public class Connection {
private Connection() { private Connection() {
packetManager.registerPackets(); packetManager.registerPackets();
changeSupport = new PropertyChangeSupport(this);
} }
public static Connection getInstance() { public static Connection getInstance() {
@ -51,6 +62,8 @@ public class Connection {
public PacketHandlerRunnable(Connection connection) { public PacketHandlerRunnable(Connection connection) {
this.connection = connection; this.connection = connection;
connection.packetHandler.addAllPacketsToMap(connection.packetManager.getPacketMap().values());
} }
@Override @Override
@ -69,6 +82,8 @@ public class Connection {
if (!connection.getPacketHandler().runPacket(packet)) { if (!connection.getPacketHandler().runPacket(packet)) {
connection.queuedPackets.add(packet); connection.queuedPackets.add(packet);
} }
connection.updateConnectionStats();
} catch (SocketException e) { } catch (SocketException e) {
break; break;
} catch (IOException e) { } catch (IOException e) {
@ -83,6 +98,91 @@ public class Connection {
handlerThread.start(); handlerThread.start();
} }
public void updateConnectionStats() {
if (!recordStats) return;
StringBuilder sb = new StringBuilder();
PacketMetrics m = packetManager.getPacketMetrics();
sb.append("OUTGOING CONNECTION\n");
sb.append("Packets Sent: ").append(m.getTotalPacketsSentCount()).append("\n");
sb.append("Bytes Sent: ").append(m.getTotalPacketsSentBytes()).append(" B\n");
sb.append("Common Types:\n");
m.getSendHistory().values().stream()
.filter(packets -> !packets.isEmpty())
.sorted((o1, o2) -> Integer.compare(o2.size(), o1.size()))
.limit(4)
.forEachOrdered(packets -> {
sb .append(" ")
.append(packets.size())
.append("x ")
.append(packets.get(0).getClass().getSimpleName())
.append("\n");
});
sb.append("\n");
sb.append("INCOMING CONNECTION\n");
sb.append("Packets Received: ").append(m.getTotalPacketsReceivedCount()).append("\n");
sb.append("Packets in Queue: ").append(getQueueSize()).append("\n");
sb.append("Bytes Received: ").append(m.getTotalPacketsReceivedBytes()).append(" B\n");
sb.append("Common Types:\n");
m.getReceiveHistory().values().stream()
.filter(packets -> !packets.isEmpty())
.sorted((o1, o2) -> Integer.compare(o2.size(), o1.size()))
.limit(4)
.forEachOrdered(packets -> {
sb .append(" ")
.append(packets.size())
.append("x ")
.append(packets.get(0).getClass().getSimpleName())
.append("\n");
});
compiledConnectionStats = sb.toString();
changeSupport.firePropertyChange("connectionStatsChange", null, compiledConnectionStats);
}
public void updateCacheStats() {
if (!recordStats) return;
StringBuilder sb = new StringBuilder();
sb.append("DATA CACHE\n");
sb.append("Users Cached: ").append(userCache.getSize()).append("\n");
sb.append("Topics Cached: ").append(topicCache.getSize()).append("\n");
compiledCacheStats = sb.toString();
changeSupport.firePropertyChange("cacheStatsChange", null, compiledCacheStats);
}
public boolean isRecordStats() {
return recordStats;
}
public String getCompiledConnectionStats() {
return compiledConnectionStats;
}
public String getCompiledCacheStats() {
return compiledCacheStats;
}
public void setRecordStats(boolean recordStats) {
this.recordStats = recordStats;
}
public void addStatListener(PropertyChangeListener listener) {
changeSupport.addPropertyChangeListener(listener);
}
public void removeStatListener(PropertyChangeListener listener) {
changeSupport.removePropertyChangeListener(listener);
}
public Socket getSocket() { public Socket getSocket() {
return socket; return socket;
} }
@ -134,6 +234,8 @@ public class Connection {
continue; continue;
} }
updateConnectionStats();
return packet; return packet;
} }
} }
@ -192,6 +294,10 @@ public class Connection {
} }
} }
public int getQueueSize() {
return queuedPackets.size();
}
public SecretUser getSelf() { public SecretUser getSelf() {
return self; return self;
} }
@ -230,7 +336,20 @@ public class Connection {
}); });
public User getUser(long userID) { public User getUser(long userID) {
return userCache.get(userID); int prevSize = userCache.getSize();
User result = userCache.get(userID);
if (prevSize != userCache.getSize())
updateCacheStats();
return result;
}
public void saveUser(User user) {
userCache.put(user.getUserID(), user);
updateCacheStats();
} }
private final DataCache<Topic> topicCache = new DataCache<>(id -> { private final DataCache<Topic> topicCache = new DataCache<>(id -> {
@ -263,6 +382,19 @@ public class Connection {
}); });
public Topic getTopic(long topicID) { public Topic getTopic(long topicID) {
return topicCache.get(topicID); int prevSize = topicCache.getSize();
Topic result = topicCache.get(topicID);
if (prevSize != topicCache.getSize())
updateCacheStats();
return result;
}
public void saveTopic(Topic topic) {
topicCache.put(topic.getId(), topic);
updateCacheStats();
} }
} }

View File

@ -30,6 +30,7 @@ public class DataCache<T> {
T result; T result;
synchronized (cache) {
if ((result = cache.getOrDefault(id, null)) != null) { if ((result = cache.getOrDefault(id, null)) != null) {
return result; return result;
} }
@ -38,6 +39,7 @@ public class DataCache<T> {
if (result != null) { if (result != null) {
cache.put(id, result); cache.put(id, result);
} }
}
return result; return result;
} }
@ -45,4 +47,8 @@ public class DataCache<T> {
public void put(long id, T val) { public void put(long id, T val) {
cache.put(id, val); cache.put(id, val);
} }
public int getSize() {
return cache.size();
}
} }

View File

@ -0,0 +1,120 @@
package dev.wiing.gossip.client.controllers;
import dev.wiing.gossip.client.data.UserAvatar;
import dev.wiing.gossip.client.generic.Pair;
import dev.wiing.gossip.client.utils.Utils;
import dev.wiing.gossip.lib.models.User;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.geometry.Pos;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.effect.ColorAdjust;
import javafx.scene.effect.Effect;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import java.net.URL;
import java.util.ResourceBundle;
public class AvatarController implements Initializable {
private VBox selectedAvatarPair;
private UserAvatar selectedAvatar;
@FXML
private FlowPane flowAvatars;
@FXML
private Label lblSelectedDesc;
@FXML
private Label lblSelectedName;
@FXML
private Pane paneSelectedIcon;
@FXML
private VBox vboxAvatarButton;
@FXML
private Button btnSave;
@FXML
private VBox vboxRoot;
@Override
public void initialize(URL location, ResourceBundle resources) {
flowAvatars.getChildren().clear();
UserAvatar.MORNING.applyToRegionBackground(paneSelectedIcon, true);
Effect effect = new ColorAdjust(0.0, -1.0, -0.4, 0.0);
for (UserAvatar avatar : UserAvatar.getAvatars()) {
VBox vBox = new VBox();
vBox.getStyleClass().add("clickable");
vBox.setSpacing(16);
vBox.setAlignment(Pos.CENTER);
Pane pane = new Pane();
pane.setMinSize(64, 64);
pane.setMaxSize(64, 64);
avatar.applyToRegionBackground(pane, true);
Label label = new Label();
label.setText(avatar.getName());
label.getStyleClass().addAll("axis", "accent");
vBox.getChildren().addAll(pane, label);
vBox.setEffect(effect);
vBox.setOnMouseClicked(event -> {
if (selectedAvatarPair != null) {
selectedAvatarPair.setEffect(effect);
}
selectedAvatarPair = vBox;
selectAvatar(avatar);
vBox.setEffect(null);
});
flowAvatars.getChildren().add(vBox);
}
}
public void selectAvatar(UserAvatar avatar) {
selectedAvatar = avatar;
lblSelectedName.setText(avatar.getName());
lblSelectedDesc.setText(avatar.getDescription());
avatar.applyToRegionBackground(paneSelectedIcon, false);
vboxRoot.setStyle("-accent: " + selectedAvatar.getColor());
}
public UserAvatar getSelectedAvatar() {
return selectedAvatar;
}
public void setOnSave(EventHandler<ActionEvent> eventHandler) {
btnSave.setOnAction(event -> {
btnSave.getScene().getWindow().hide();
eventHandler.handle(event);
});
}
public static Pair<Parent, AvatarController> createInstance() {
return Utils.createInstance("views/avatar-view.fxml");
}
}

View File

@ -3,6 +3,7 @@ package dev.wiing.gossip.client.controllers;
import dev.wiing.gossip.client.Connection; import dev.wiing.gossip.client.Connection;
import dev.wiing.gossip.client.data.UserAvatar; import dev.wiing.gossip.client.data.UserAvatar;
import dev.wiing.gossip.client.utils.Utils;
import dev.wiing.gossip.lib.models.SecretUser; import dev.wiing.gossip.lib.models.SecretUser;
import dev.wiing.gossip.lib.packets.RegisterCredentialsPacket; import dev.wiing.gossip.lib.packets.RegisterCredentialsPacket;
import dev.wiing.gossip.lib.packets.Packet; import dev.wiing.gossip.lib.packets.Packet;
@ -35,6 +36,11 @@ public class LoginController implements Initializable {
@FXML @FXML
private TextField txtUsername; private TextField txtUsername;
@FXML
private VBox vboxRoot;
private UserAvatar selectedAvatar = UserAvatar.MORNING;
@Override @Override
public void initialize(URL location, ResourceBundle resources) { public void initialize(URL location, ResourceBundle resources) {
UserAvatar.MORNING.applyToRegionBackground(paneIcon, false); UserAvatar.MORNING.applyToRegionBackground(paneIcon, false);
@ -42,17 +48,31 @@ public class LoginController implements Initializable {
Circle circle = new Circle(paneIcon.getMinWidth() * 0.5); Circle circle = new Circle(paneIcon.getMinWidth() * 0.5);
paneIcon.setShape(circle); paneIcon.setShape(circle);
hboxEditOverlay.setShape(circle); hboxEditOverlay.setShape(circle);
vboxRoot.setStyle("-accent: " + selectedAvatar.getColor());
} }
@FXML @FXML
public void onChangeIcon(MouseEvent event) { public void onChangeIcon(MouseEvent event) {
var pair = AvatarController.createInstance();
pair.second().selectAvatar(selectedAvatar);
Utils.openParentAsWindow(pair.first(), "Gossip -- Avatar Menu");
pair.second().setOnSave(event1 -> {
selectedAvatar = pair.second().getSelectedAvatar();
selectedAvatar.applyToRegionBackground(paneIcon, false);
vboxRoot.setStyle("-accent: " + selectedAvatar.getColor());
});
} }
@FXML @FXML
public void onLogin(ActionEvent event) { public void onLogin(ActionEvent event) {
RegisterRequestPacket packet = new RegisterRequestPacket(); RegisterRequestPacket packet = new RegisterRequestPacket();
packet.setAvatarID((byte)0); packet.setAvatarID((byte)selectedAvatar.getId());
packet.setUsername(txtUsername.getText()); packet.setUsername(txtUsername.getText());
Connection.getInstance().sendPacket(packet); Connection.getInstance().sendPacket(packet);

View File

@ -6,17 +6,22 @@ import dev.wiing.gossip.client.controllers.item.SystemMessageItemController;
import dev.wiing.gossip.client.generic.Pair; import dev.wiing.gossip.client.generic.Pair;
import dev.wiing.gossip.client.utils.Utils; import dev.wiing.gossip.client.utils.Utils;
import dev.wiing.gossip.lib.models.SystemMessage; import dev.wiing.gossip.lib.models.SystemMessage;
import dev.wiing.gossip.lib.models.User;
import dev.wiing.gossip.lib.models.UserMessage; import dev.wiing.gossip.lib.models.UserMessage;
import dev.wiing.gossip.lib.models.Topic; import dev.wiing.gossip.lib.models.Topic;
import dev.wiing.gossip.lib.packets.MessagePushPacket; import dev.wiing.gossip.lib.packets.MessagePushPacket;
import dev.wiing.gossip.lib.packets.TopicJoinPacket; import dev.wiing.gossip.lib.packets.TopicJoinPacket;
import dev.wiing.gossip.lib.packets.TypingPingPacket;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TextArea; import javafx.scene.control.TextArea;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent; import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeEvent;
@ -64,6 +69,10 @@ public class MainChatController {
@FXML @FXML
private VBox vboxVisitPage; private VBox vboxVisitPage;
private Thread typingWaitThread = null;
private boolean composeFieldPreviouslyEmpty = true;
public void setTopic(Topic topic) { public void setTopic(Topic topic) {
this.topic = topic; this.topic = topic;
@ -82,7 +91,7 @@ public class MainChatController {
}); });
} }
if (evt.getPropertyName().equals("messageAdd")) { else if (evt.getPropertyName().equals("messageAdd")) {
Platform.runLater(() -> { Platform.runLater(() -> {
if (evt.getNewValue() instanceof UserMessage) { if (evt.getNewValue() instanceof UserMessage) {
addMessage((UserMessage)evt.getNewValue()); addMessage((UserMessage)evt.getNewValue());
@ -93,7 +102,13 @@ public class MainChatController {
} }
}); });
} }
else if (evt.getPropertyName().equals("typingAdd") || evt.getPropertyName().equals("typingRemove")) {
Platform.runLater(this::updateTypingMembers);
}
}); });
updateTypingMembers();
} }
private void setTopicName(String name) { private void setTopicName(String name) {
@ -163,24 +178,111 @@ public class MainChatController {
} }
} }
@FXML private void sendMessage() {
void onSend(MouseEvent event) {
String messageContents = txtCompose.getText(); String messageContents = txtCompose.getText();
if (messageContents.isBlank()) {
return;
}
composeFieldPreviouslyEmpty = true;
txtCompose.clear(); txtCompose.clear();
MessagePushPacket packet = new MessagePushPacket(); MessagePushPacket packet = new MessagePushPacket();
packet.setTopicID(topic.getId()); packet.setTopicID(topic.getId());
packet.setMessage(messageContents); packet.setMessage(messageContents.strip());
Connection.getInstance().sendPacketAuthenticated(packet); Connection.getInstance().sendPacketAuthenticated(packet);
try { try {
Connection.getInstance().findAck(MessagePushPacket.TYPE); Connection.getInstance().findAck(MessagePushPacket.TYPE);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
TypingPingPacket pingPacket = new TypingPingPacket();
pingPacket.setTyping(false);
pingPacket.setTopicID(topic.getId());
Connection.getInstance().sendPacketAuthenticated(pingPacket);
typingWaitThread.interrupt();
typingWaitThread = null;
}
@FXML
void onSend(MouseEvent event) {
sendMessage();
}
@FXML
void onKeyTyped(KeyEvent event) {
boolean isBlankTextField = txtCompose.getText().isBlank();
if (!isBlankTextField && typingWaitThread != null && typingWaitThread.isAlive()) return;
if (isBlankTextField && composeFieldPreviouslyEmpty) return;
TypingPingPacket packet = new TypingPingPacket();
packet.setTyping(!isBlankTextField);
packet.setTopicID(topic.getId());
Connection.getInstance().sendPacketAuthenticated(packet);
composeFieldPreviouslyEmpty = isBlankTextField;
if (typingWaitThread != null) typingWaitThread.interrupt();
if (isBlankTextField) return;
typingWaitThread = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException ignored) {
}
});
typingWaitThread.start();
}
@FXML
void onKeyPressed(KeyEvent event) {
if (event.getCode() == KeyCode.ENTER) {
if (event.isShiftDown()) {
txtCompose.insertText(txtCompose.getLength(), "\n");
} else {
sendMessage();
}
}
}
public void updateTypingMembers() {
if (topic.getTypingUsersCount() == 0) {
lblTyping.setText("");
lblTyping.setMinHeight(0);
lblTyping.setMaxHeight(0);
return;
}
lblTyping.setMinHeight(Region.USE_COMPUTED_SIZE);
lblTyping.setMaxHeight(Region.USE_COMPUTED_SIZE);
if (topic.getTypingUsersCount() > 3) {
lblTyping.setText("Several babblers are writing...");
return;
}
StringBuilder sb = new StringBuilder();
for (User user : topic.getTypingUsersReadOnly()) {
if (!sb.isEmpty()) sb.append(", ");
sb.append(user.getUsername());
}
sb.append(topic.getTypingUsersCount() == 1 ? " is" : " are");
sb.append(" writing...");
lblTyping.setText(sb.toString());
} }
public static Pair<Parent, MainChatController> createInstance() { public static Pair<Parent, MainChatController> createInstance() {
@ -188,3 +290,6 @@ public class MainChatController {
} }
} }
// TODO: better mutliline input
// 25 + 17 per extra line

View File

@ -5,6 +5,7 @@ import dev.wiing.gossip.client.controllers.item.TopicItemController;
import dev.wiing.gossip.client.data.UserAvatar; import dev.wiing.gossip.client.data.UserAvatar;
import dev.wiing.gossip.client.generic.Pair; import dev.wiing.gossip.client.generic.Pair;
import dev.wiing.gossip.client.utils.Utils; import dev.wiing.gossip.client.utils.Utils;
import dev.wiing.gossip.lib.PacketHandler;
import dev.wiing.gossip.lib.data.LongData; import dev.wiing.gossip.lib.data.LongData;
import dev.wiing.gossip.lib.models.*; import dev.wiing.gossip.lib.models.*;
import dev.wiing.gossip.lib.packets.*; import dev.wiing.gossip.lib.packets.*;
@ -14,15 +15,16 @@ import javafx.fxml.FXML;
import javafx.fxml.Initializable; import javafx.fxml.Initializable;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.AnchorPane; import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Pane; import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import java.net.SocketException; import java.net.SocketException;
import java.net.URL; import java.net.URL;
import java.util.Map; import java.util.*;
import java.util.ResourceBundle;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class MainController implements Initializable { public class MainController implements Initializable {
@ -48,6 +50,11 @@ public class MainController implements Initializable {
@FXML @FXML
private Label lblJoinMessage; private Label lblJoinMessage;
@FXML
private Label lblDebug;
private boolean recordDebugStats = false;
@Override @Override
public void initialize(URL url, ResourceBundle resourceBundle) { public void initialize(URL url, ResourceBundle resourceBundle) {
User user = Connection.getInstance().getSelf(); User user = Connection.getInstance().getSelf();
@ -74,7 +81,19 @@ public class MainController implements Initializable {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
Connection.getInstance().getPacketHandler().addListener(TopicCreatedPacket.class, packet -> { setUpPacketHandler(Connection.getInstance().getPacketHandler());
Connection.getInstance().beginHandlingPackets();
vboxTopics.getChildren().clear();
Connection.getInstance().addStatListener(evt -> {
Platform.runLater(this::rebuildDebugStatText);
});
}
private void setUpPacketHandler(PacketHandler packetHandler) {
packetHandler.addListener(TopicCreatedPacket.class, packet -> {
User host = Connection.getInstance().getUser(packet.getHostID()); User host = Connection.getInstance().getUser(packet.getHostID());
Topic topic = new Topic( Topic topic = new Topic(
@ -85,10 +104,12 @@ public class MainController implements Initializable {
packet.getTopicColor() packet.getTopicColor()
); );
Connection.getInstance().saveTopic(topic);
addTopic(topic); addTopic(topic);
}); });
Connection.getInstance().getPacketHandler().addListener(TopicUpdatePacket.class, packet -> { packetHandler.addListener(TopicUpdatePacket.class, packet -> {
if (!topicMap.containsKey(packet.getTopicID())) return; if (!topicMap.containsKey(packet.getTopicID())) return;
Topic topic = topicMap.get(packet.getTopicID()).second().getTopic(); Topic topic = topicMap.get(packet.getTopicID()).second().getTopic();
@ -111,7 +132,7 @@ public class MainController implements Initializable {
} }
}); });
Connection.getInstance().getPacketHandler().addListener(MessageCreatedPacket.class, packet -> { packetHandler.addListener(MessageCreatedPacket.class, packet -> {
if (!topicMap.containsKey(packet.getTopicID())) return; if (!topicMap.containsKey(packet.getTopicID())) return;
Topic topic = topicMap.get(packet.getTopicID()).second().getTopic(); Topic topic = topicMap.get(packet.getTopicID()).second().getTopic();
@ -123,7 +144,7 @@ public class MainController implements Initializable {
topic.addMessage(userMessage); topic.addMessage(userMessage);
}); });
Connection.getInstance().getPacketHandler().addListener(MessageSystemPacket.class, packet -> { packetHandler.addListener(MessageSystemPacket.class, packet -> {
if (!topicMap.containsKey(packet.getTopicID())) return; if (!topicMap.containsKey(packet.getTopicID())) return;
Topic topic = topicMap.get(packet.getTopicID()).second().getTopic(); Topic topic = topicMap.get(packet.getTopicID()).second().getTopic();
@ -138,9 +159,22 @@ public class MainController implements Initializable {
topic.addMessage(systemMessage); topic.addMessage(systemMessage);
}); });
Connection.getInstance().beginHandlingPackets(); packetHandler.addListener(TypingListUpdatePacket.class, packet -> {
if (!topicMap.containsKey(packet.getTopicID())) return;
vboxTopics.getChildren().clear(); Topic topic = topicMap.get(packet.getTopicID()).second().getTopic();
Set<Long> unexplored = topic.getTypingUsersReadOnly().stream().map(User::getUserID).collect(Collectors.toSet());
for (LongData typingMember : packet.getTypingMembers()) {
topic.addTypingUser(Connection.getInstance().getUser(typingMember.getValue()));
unexplored.remove(typingMember.getValue());
}
for (Long userID : unexplored) {
topic.removeTypingUser(Connection.getInstance().getUser(userID));
}
});
} }
private void fetchTopicMessage(Topic topic, long messageID) { private void fetchTopicMessage(Topic topic, long messageID) {
@ -265,6 +299,45 @@ public class MainController implements Initializable {
Connection.getInstance().sendPacket(packet); Connection.getInstance().sendPacket(packet);
} }
@FXML
void onToggleDebug(MouseEvent event) {
recordDebugStats = !recordDebugStats;
lblDebug.setVisible(recordDebugStats);
Connection.getInstance().setRecordStats(recordDebugStats);
Connection.getInstance().updateConnectionStats();
Connection.getInstance().updateCacheStats();
}
@FXML
void onDebugEnter(MouseEvent event) {
lblDebug.setOpacity(0.9);
}
@FXML
void onDebugLeave(MouseEvent event) {
lblDebug.setOpacity(0.2);
}
private void rebuildDebugStatText() {
StringBuilder sb = new StringBuilder();
sb.append(Connection.getInstance().getCompiledConnectionStats()).append("\n");
sb.append(Connection.getInstance().getCompiledCacheStats()).append("\n");
sb.append("STATS\n");
sb.append("Total Messages (Downloaded): ").append(topicMap.values().stream()
.map(Pair::second)
.map(MainChatController::getTopic)
.map(Topic::getMessagesReadOnly)
.map(List::size)
.reduce(Integer::sum)
.orElse(0));
lblDebug.setText(sb.toString());
}
public static Pair<Parent, MainController> createInstance() { public static Pair<Parent, MainController> createInstance() {
return Utils.createInstance("views/main-view.fxml"); return Utils.createInstance("views/main-view.fxml");
} }

View File

@ -11,16 +11,16 @@ import java.util.Arrays;
public class UserAvatar { public class UserAvatar {
public static final UserAvatar MORNING = new UserAvatar("Morning", "Eggs and Bacon", 0, "avatars/avatar-0.png"); public static final UserAvatar MORNING = new UserAvatar("Morning", "Eggs and Bacon", 0, "#facb01", "avatars/avatar-0.png");
public static final UserAvatar VINTAGE = new UserAvatar("Vintage", "Red Car", 1, "avatars/avatar-1.png"); public static final UserAvatar VINTAGE = new UserAvatar("Vintage", "Red Car", 1, "#dd2046", "avatars/avatar-1.png");
public static final UserAvatar ROUTINE = new UserAvatar("Routine", "Coffee Cup", 2, "avatars/avatar-2.png"); public static final UserAvatar ROUTINE = new UserAvatar("Routine", "Coffee Cup", 2, "#01b4da", "avatars/avatar-2.png");
public static final UserAvatar SILLY = new UserAvatar("Silly", "Ghost Toy", 3, "avatars/avatar-3.png"); public static final UserAvatar SILLY = new UserAvatar("Silly", "Ghost Toy", 3, "#8f58d8", "avatars/avatar-3.png");
public static final UserAvatar ADVENTURE = new UserAvatar("Adventure", "Slide with a Plant", 4, "avatars/avatar-4.png"); public static final UserAvatar ADVENTURE = new UserAvatar("Adventure", "Slide with a Plant", 4, "#6bc7da", "avatars/avatar-4.png");
public static final UserAvatar REBELLION = new UserAvatar("Rebellion", "Potted Cactus", 5, "avatars/avatar-5.png"); public static final UserAvatar REBELLION = new UserAvatar("Rebellion", "Potted Cactus", 5, "#17b479", "avatars/avatar-5.png");
public static final UserAvatar DIGITAL = new UserAvatar("Digital", "Game Controller", 6, "avatars/avatar-6.png"); public static final UserAvatar DIGITAL = new UserAvatar("Digital", "Game Controller", 6, "#c3c1cf", "avatars/avatar-6.png");
public static final UserAvatar MYSTERY = new UserAvatar("Mystery", "Floating Sphere", 7, "avatars/avatar-7.png"); public static final UserAvatar MYSTERY = new UserAvatar("Mystery", "Floating Sphere", 7, "#26badc", "avatars/avatar-7.png");
public static final UserAvatar VACATION = new UserAvatar("Vacation", "Cat under an Umbrella", 8, "avatars/avatar-8.png"); public static final UserAvatar VACATION = new UserAvatar("Vacation", "Cat under an Umbrella", 8, "#f994fb", "avatars/avatar-8.png");
public static final UserAvatar SIMPLE = new UserAvatar("Simple", "Potted Succulent", 9, "avatars/avatar-9.png"); public static final UserAvatar SIMPLE = new UserAvatar("Simple", "Potted Succulent", 9, "#ff7e29", "avatars/avatar-9.png");
public static UserAvatar getAvatar(int avatarID) { public static UserAvatar getAvatar(int avatarID) {
return Arrays.stream(getAvatars()) return Arrays.stream(getAvatars())
@ -47,12 +47,14 @@ public class UserAvatar {
private final String name; private final String name;
private final String description; private final String description;
private final int id; private final int id;
private final String color;
private final URL url; private final URL url;
private UserAvatar(String name, String description, int id, String path) { private UserAvatar(String name, String description, int id, String color, String path) {
this.name = name; this.name = name;
this.description = description; this.description = description;
this.id = id; this.id = id;
this.color = color;
this.url = Program.class.getResource(path); this.url = Program.class.getResource(path);
} }
@ -68,6 +70,10 @@ public class UserAvatar {
return id; return id;
} }
public String getColor() {
return color;
}
public URL getUrl() { public URL getUrl() {
return url; return url;
} }

View File

@ -1,27 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.String?>
<?import java.net.URL?> <?import java.net.URL?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.FlowPane?> <?import javafx.scene.layout.FlowPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Pane?> <?import javafx.scene.layout.Pane?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<VBox fx:id="vboxRoot" alignment="CENTER" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="621.0" spacing="16.0" styleClass="gradient" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.AvatarController">
<VBox alignment="CENTER" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" styleClass="gradient" xmlns="http://javafx.com/javafx/20.0.1" xmlns:fx="http://javafx.com/fxml/1"> <children>
<VBox alignment="CENTER" spacing="16.0" VBox.vgrow="ALWAYS">
<children> <children>
<Label style="-fx-font-size: 20;" styleClass="axis" text="Choose an Avatar" /> <Label style="-fx-font-size: 20;" styleClass="axis" text="Choose an Avatar" />
<FlowPane prefHeight="200.0" prefWidth="200.0"> <Button fx:id="btnSave" mnemonicParsing="false" styleClass="axis" text="Save" />
<ScrollPane fitToWidth="true" hbarPolicy="NEVER" vbarPolicy="ALWAYS" VBox.vgrow="ALWAYS">
<styleClass>
<String fx:value="container" />
<String fx:value="list" />
<String fx:value="transparent" />
</styleClass>
<VBox.margin>
<Insets left="11.0" />
</VBox.margin>
<padding>
<Insets left="8.0" right="8.0" />
</padding>
<content>
<FlowPane fx:id="flowAvatars" alignment="CENTER" columnHalignment="CENTER" hgap="16.0" prefWrapLength="0.0" vgap="16.0">
<children> <children>
<VBox prefHeight="200.0" prefWidth="100.0"> <VBox fx:id="vboxAvatarButton" alignment="CENTER" spacing="16.0">
<children> <children>
<Pane maxHeight="64.0" maxWidth="64.0" minHeight="64.0" minWidth="64.0" /> <Pane maxHeight="64.0" maxWidth="64.0" minHeight="64.0" minWidth="64.0" />
<Label text="AvatarName">
<styleClass>
<String fx:value="axis" />
<String fx:value="accent" />
</styleClass>
</Label>
</children> </children>
</VBox> </VBox>
</children> </children>
</FlowPane> </FlowPane>
</content>
</ScrollPane>
</children>
</VBox>
<HBox alignment="CENTER" spacing="16.0">
<children>
<Pane fx:id="paneSelectedIcon" maxHeight="96.0" maxWidth="96.0" minHeight="96.0" minWidth="96.0" />
<VBox alignment="CENTER_LEFT" HBox.hgrow="ALWAYS">
<children>
<Label text="Selected">
<styleClass>
<String fx:value="axis" />
<String fx:value="faint" />
</styleClass>
</Label>
<Label fx:id="lblSelectedName" style="-fx-font-size: 16;" text="AvatarName">
<styleClass>
<String fx:value="axis" />
<String fx:value="accent" />
</styleClass>
</Label>
<Label fx:id="lblSelectedDesc" style="-fx-font-size: 14;" text="Description" />
</children>
</VBox>
</children>
</HBox>
</children> </children>
<stylesheets> <stylesheets>
<URL value="@../styling.css" /> <URL value="@../styling.css" />
<URL value="@../custom.css" /> <URL value="@../custom.css" />
<URL value="@../scroll.css" />
</stylesheets> </stylesheets>
<padding>
<Insets bottom="16.0" left="16.0" right="16.0" top="16.0" />
</padding>
</VBox> </VBox>

View File

@ -15,7 +15,7 @@
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import javafx.scene.shape.Circle?> <?import javafx.scene.shape.Circle?>
<AnchorPane prefHeight="318.0" prefWidth="523.0" xmlns="http://javafx.com/javafx/20.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.MainChatController"> <AnchorPane prefHeight="318.0" prefWidth="523.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.MainChatController">
<children> <children>
<VBox fx:id="vboxVisitPage" alignment="CENTER" layoutX="10.0" layoutY="10.0" spacing="8.0" visible="false" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0"> <VBox fx:id="vboxVisitPage" alignment="CENTER" layoutX="10.0" layoutY="10.0" spacing="8.0" visible="false" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
<children> <children>
@ -88,7 +88,7 @@
<String fx:value="list" /> <String fx:value="list" />
</styleClass> </styleClass>
<children> <children>
<TextArea fx:id="txtCompose" maxHeight="1.7976931348623157E308" minHeight="1.0" prefHeight="25.0" prefRowCount="1" promptText="Compose..." styleClass="transparent" wrapText="true" HBox.hgrow="ALWAYS" /> <TextArea fx:id="txtCompose" maxHeight="1.7976931348623157E308" minHeight="1.0" onKeyPressed="#onKeyPressed" onKeyTyped="#onKeyTyped" prefHeight="25.0" prefRowCount="1" promptText="Compose..." styleClass="transparent" wrapText="true" HBox.hgrow="ALWAYS" />
<ImageView fitHeight="20.0" fitWidth="20.0" onMouseClicked="#onSend" opacity="0.5" pickOnBounds="true" preserveRatio="true"> <ImageView fitHeight="20.0" fitWidth="20.0" onMouseClicked="#onSend" opacity="0.5" pickOnBounds="true" preserveRatio="true">
<image> <image>
<Image url="@../icons/icon-send-2.png" /> <Image url="@../icons/icon-send-2.png" />

View File

@ -12,7 +12,7 @@
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<VBox alignment="CENTER" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" spacing="16.0" styleClass="gradient" xmlns="http://javafx.com/javafx/20.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.LoginController"> <VBox fx:id="vboxRoot" alignment="CENTER" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" spacing="16.0" styleClass="gradient" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.LoginController">
<stylesheets> <stylesheets>
<URL value="@../styling.css" /> <URL value="@../styling.css" />
<URL value="@../scroll.css" /> <URL value="@../scroll.css" />
@ -32,7 +32,7 @@
</Label> </Label>
<HBox alignment="CENTER" spacing="16.0" VBox.vgrow="ALWAYS"> <HBox alignment="CENTER" spacing="16.0" VBox.vgrow="ALWAYS">
<children> <children>
<AnchorPane fx:id="paneIcon" maxHeight="64.0" maxWidth="64.0" minHeight="64.0" minWidth="64.0" onMouseClicked="#onChangeIcon" onMouseEntered="#onIconMouseEnter" onMouseExited="#onIconMouseExit" style="-fx-background-color: white;"> <AnchorPane fx:id="paneIcon" maxHeight="64.0" maxWidth="64.0" minHeight="64.0" minWidth="64.0" onMouseClicked="#onChangeIcon" onMouseEntered="#onIconMouseEnter" onMouseExited="#onIconMouseExit" style="-fx-background-color: white;" styleClass="clickable">
<children> <children>
<HBox fx:id="hboxEditOverlay" alignment="CENTER" mouseTransparent="true" opacity="0.0" style="-fx-background-color: #0006;" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0"> <HBox fx:id="hboxEditOverlay" alignment="CENTER" mouseTransparent="true" opacity="0.0" style="-fx-background-color: #0006;" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
<children> <children>

View File

@ -17,7 +17,10 @@
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import javafx.scene.shape.Circle?> <?import javafx.scene.shape.Circle?>
<VBox fx:id="vboxRoot" alignment="TOP_RIGHT" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="486.0" prefWidth="750.0" spacing="16.0" style="-accent: hsb(0, 0%, 50%);;" styleClass="gradient" xmlns="http://javafx.com/javafx/20.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.MainController">
<AnchorPane stylesheets="@../styling.css" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="dev.wiing.gossip.client.controllers.MainController">
<children>
<VBox fx:id="vboxRoot" alignment="TOP_RIGHT" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="486.0" prefWidth="750.0" spacing="16.0" style="-accent: hsb(0, 0%, 50%);;" styleClass="gradient" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
<stylesheets> <stylesheets>
<URL value="@../styling.css" /> <URL value="@../styling.css" />
<URL value="@../scroll.css" /> <URL value="@../scroll.css" />
@ -26,7 +29,7 @@
<children> <children>
<HBox> <HBox>
<children> <children>
<ImageView blendMode="OVERLAY" fitHeight="30.0" fitWidth="200.0" pickOnBounds="true" preserveRatio="true"> <ImageView blendMode="OVERLAY" fitHeight="30.0" fitWidth="200.0" onMouseClicked="#onToggleDebug" onMouseEntered="#onDebugEnter" onMouseExited="#onDebugLeave" pickOnBounds="true" preserveRatio="true">
<image> <image>
<Image url="@../logo.png" /> <Image url="@../logo.png" />
</image> </image>
@ -270,7 +273,8 @@
</children> </children>
<padding> <padding>
<Insets left="16.0" /> <Insets left="16.0" />
</padding></AnchorPane> </padding>
</AnchorPane>
</items> </items>
</SplitPane> </SplitPane>
</children> </children>
@ -278,3 +282,17 @@
<Insets bottom="16.0" left="16.0" right="16.0" top="16.0" /> <Insets bottom="16.0" left="16.0" right="16.0" top="16.0" />
</padding> </padding>
</VBox> </VBox>
<VBox alignment="BOTTOM_RIGHT" mouseTransparent="true" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
<children>
<Label fx:id="lblDebug" alignment="BOTTOM_RIGHT" opacity="0.2" style="-fx-background-color: #000f;" styleClass="border-radius-small" text="Debug" visible="false">
<padding>
<Insets bottom="8.0" left="8.0" right="8.0" top="8.0" />
</padding>
</Label>
</children>
<padding>
<Insets bottom="16.0" left="16.0" right="16.0" top="16.0" />
</padding>
</VBox>
</children>
</AnchorPane>

View File

@ -8,8 +8,23 @@ import java.util.stream.Collectors;
public class PacketHandler { public class PacketHandler {
private final Map<Class<? extends Packet>, Packet> packetClassMap = new HashMap<>();
private final Map<Class<? extends Packet>, Set<PacketListener<? extends Packet>>> listeners = new ConcurrentHashMap<>(); private final Map<Class<? extends Packet>, Set<PacketListener<? extends Packet>>> listeners = new ConcurrentHashMap<>();
public void clearPacketMap() {
packetClassMap.clear();
}
public void addPacketToMap(Packet packet) {
packetClassMap.put(packet.getClass(), packet);
}
public void addAllPacketsToMap(Collection<Packet> packets) {
packetClassMap.putAll(packets.stream()
.collect(Collectors.toMap(Packet::getClass, o -> o)));
}
public <T extends Packet> void addListener(Class<T> type, PacketListener<T> listener) { public <T extends Packet> void addListener(Class<T> type, PacketListener<T> listener) {
if (!listeners.containsKey(type)) { if (!listeners.containsKey(type)) {
listeners.put(type, new HashSet<>()); listeners.put(type, new HashSet<>());
@ -39,35 +54,25 @@ public class PacketHandler {
} }
public boolean runPacket(Packet packet) { public boolean runPacket(Packet packet) {
return switch (packet.getType()) { return runPacket((Class<Packet>)packet.getClass(), packet);
case TopicCreatedPacket.TYPE -> runPacket(TopicCreatedPacket.class, (TopicCreatedPacket) packet);
case TopicUpdatePacket.TYPE -> runPacket(TopicUpdatePacket.class, (TopicUpdatePacket) packet);
case TopicListDataPacket.TYPE -> runPacket(TopicListDataPacket.class, (TopicListDataPacket) packet);
case MessageCreatedPacket.TYPE -> runPacket(MessageCreatedPacket.class, (MessageCreatedPacket) packet);
case MessageSystemPacket.TYPE -> runPacket(MessageSystemPacket.class, (MessageSystemPacket) packet);
default -> false;
};
} }
public <T extends Packet> Set<PacketListener<T>> getListeners(Class<T> packetClass) { public <T extends Packet> Set<PacketListener<T>> getListeners(Class<T> packetClass) {
if (!listeners.containsKey(packetClass)) if (!listeners.containsKey(packetClass))
return null; return null;
return listeners.get(packetClass).stream().map(listener -> { return listeners.get(packetClass).stream()
return (PacketListener<T>) listener; .map(listener -> (PacketListener<T>) listener)
}).collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public List<Short> getListeningTypes() { public List<Short> getListeningTypes() {
return new ArrayList<>() { return listeners.entrySet().stream()
{ .filter(classSetEntry -> !classSetEntry.getValue().isEmpty())
add(TopicCreatedPacket.TYPE); .map(Map.Entry::getKey)
add(TopicUpdatePacket.TYPE); .map(packetClassMap::get)
add(TopicListDataPacket.TYPE); .map(Packet::getType)
add(MessageCreatedPacket.TYPE); .collect(Collectors.toList());
add(MessageSystemPacket.TYPE);
}
};
} }
} }

View File

@ -4,10 +4,7 @@ import dev.wiing.gossip.lib.packets.*;
import java.io.*; import java.io.*;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class PacketManager { public class PacketManager {
@ -38,9 +35,14 @@ public class PacketManager {
add(new MessageListDataPacket()); add(new MessageListDataPacket());
add(new MessageFetchPacket()); add(new MessageFetchPacket());
add(new MessageDataPacket()); add(new MessageDataPacket());
add(new TypingPingPacket());
add(new TypingListUpdatePacket());
} }
}; };
private final PacketMetrics packetMetrics = new PacketMetrics();
private final Map<Short, Packet> packetMap = new HashMap<>(); private final Map<Short, Packet> packetMap = new HashMap<>();
public void addPacket(Packet packet) { public void addPacket(Packet packet) {
@ -58,6 +60,14 @@ public class PacketManager {
} }
} }
public Map<Short, Packet> getPacketMap() {
return Collections.unmodifiableMap(packetMap);
}
public PacketMetrics getPacketMetrics() {
return packetMetrics;
}
public Packet readPacket(InputStream stream) throws IOException { public Packet readPacket(InputStream stream) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(stream.readNBytes(6)); ByteBuffer buffer = ByteBuffer.wrap(stream.readNBytes(6));
@ -68,9 +78,14 @@ public class PacketManager {
Packet packet = packetMap.getOrDefault(type, null); Packet packet = packetMap.getOrDefault(type, null);
if (packet == null) return null; if (packet == null) {
packetMetrics.recordUnrecognisedPacket();
return null;
}
return packet.readBytes(ByteBuffer.wrap(stream.readNBytes(size)), size); Packet result = packet.readBytes(ByteBuffer.wrap(stream.readNBytes(size)), size);
packetMetrics.recordReceivedPacket(result);
return result;
} }
public void writePacket(BufferedOutputStream stream, Packet packet) throws IOException { public void writePacket(BufferedOutputStream stream, Packet packet) throws IOException {
@ -82,6 +97,8 @@ public class PacketManager {
packet.writeBytes(buffer); packet.writeBytes(buffer);
stream.write(buffer.array()); stream.write(buffer.array());
packetMetrics.recordSentPacket(packet);
} }
public void writePacket(OutputStream stream, Packet packet) throws IOException { public void writePacket(OutputStream stream, Packet packet) throws IOException {

View File

@ -0,0 +1,74 @@
package dev.wiing.gossip.lib;
import dev.wiing.gossip.lib.packets.Packet;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class PacketMetrics {
private final Map<Class<Packet>, List<Packet>> sendHistory = new ConcurrentHashMap<>();
private int totalPacketsSentCount = 0;
private long totalPacketsSentBytes = 0;
private final Map<Class<Packet>, List<Packet>> receiveHistory = new ConcurrentHashMap<>();
private int totalPacketsReceivedCount = 0;
private long totalPacketsReceivedBytes = 0;
private int totalPacketsUnrecognised = 0;
public PacketMetrics() {
}
public void recordSentPacket(Packet packet) {
if (!sendHistory.containsKey(packet.getClass())) {
sendHistory.put((Class<Packet>) packet.getClass(), Collections.synchronizedList(new ArrayList<>()));
}
sendHistory.get(packet.getClass()).add(packet);
++totalPacketsSentCount;
totalPacketsSentBytes += packet.getTotalLength();
}
public void recordReceivedPacket(Packet packet) {
if (!receiveHistory.containsKey(packet.getClass())) {
receiveHistory.put((Class<Packet>) packet.getClass(), Collections.synchronizedList(new ArrayList<>()));
}
receiveHistory.get(packet.getClass()).add(packet);
++totalPacketsReceivedCount;
totalPacketsReceivedBytes += packet.getTotalLength();
}
public void recordUnrecognisedPacket() {
++totalPacketsUnrecognised;
}
public Map<Class<Packet>, List<Packet>> getSendHistory() {
return Collections.unmodifiableMap(sendHistory);
}
public int getTotalPacketsSentCount() {
return totalPacketsSentCount;
}
public long getTotalPacketsSentBytes() {
return totalPacketsSentBytes;
}
public Map<Class<Packet>, List<Packet>> getReceiveHistory() {
return Collections.unmodifiableMap(receiveHistory);
}
public int getTotalPacketsReceivedCount() {
return totalPacketsReceivedCount;
}
public long getTotalPacketsReceivedBytes() {
return totalPacketsReceivedBytes;
}
}

View File

@ -18,6 +18,8 @@ public class Topic {
private final List<Message> messages = Collections.synchronizedList(new ArrayList<>()); private final List<Message> messages = Collections.synchronizedList(new ArrayList<>());
private final Map<Long, Message> messageByIDs = new ConcurrentHashMap<>(); private final Map<Long, Message> messageByIDs = new ConcurrentHashMap<>();
private final Set<User> typingUsers = Collections.synchronizedSet(new HashSet<>());
private long messageTrackerID = 1; private long messageTrackerID = 1;
public Topic(long id, String name, String description, User host, short color) { public Topic(long id, String name, String description, User host, short color) {
@ -101,6 +103,30 @@ public class Topic {
this.changeSupport.firePropertyChange("messageAdd", null, message); this.changeSupport.firePropertyChange("messageAdd", null, message);
} }
public Set<User> getTypingUsersReadOnly() {
return Collections.unmodifiableSet(typingUsers);
}
public int getTypingUsersCount() {
return typingUsers.size();
}
public void addTypingUser(User user) {
if (typingUsers.contains(user)) return;
typingUsers.add(user);
this.changeSupport.firePropertyChange("typingAdd", null, user);
}
public void removeTypingUser(User user) {
if (!typingUsers.contains(user)) return;
typingUsers.remove(user);
this.changeSupport.firePropertyChange("typingRemove", null, user);
}
public Message getMessageByID(long messageID) { public Message getMessageByID(long messageID) {
return messageByIDs.getOrDefault(messageID, null); return messageByIDs.getOrDefault(messageID, null);
} }

View File

@ -0,0 +1,59 @@
package dev.wiing.gossip.lib.packets;
import dev.wiing.gossip.lib.data.ListData;
import dev.wiing.gossip.lib.data.LongData;
import java.nio.ByteBuffer;
import java.util.List;
public class TypingListUpdatePacket extends Packet {
public static final short TYPE = 0x42;
public static final int LENGTH = 0x000;
private long topicID;
private final ListData<LongData> typingMembers = new ListData<>(LongData::new);
public TypingListUpdatePacket() {
super(TYPE, LENGTH);
}
private void updateLength() {
setLength(8 + typingMembers.getLength());
}
@Override
public int getLength() {
updateLength();
return super.getLength();
}
public long getTopicID() {
return topicID;
}
public void setTopicID(long topicID) {
this.topicID = topicID;
}
public List<LongData> getTypingMembers() {
return typingMembers.getData();
}
@Override
public Packet readBytes(ByteBuffer buffer, int size) {
TypingListUpdatePacket packet = new TypingListUpdatePacket();
packet.setLength(size);
packet.topicID = buffer.getLong();
packet.typingMembers.setBytes(buffer);
return packet;
}
@Override
public void writeBytes(ByteBuffer buffer) {
buffer.putLong(topicID);
buffer.put(typingMembers.getBytes());
}
}

View File

@ -0,0 +1,52 @@
package dev.wiing.gossip.lib.packets;
import dev.wiing.gossip.lib.data.AuthSecret;
import java.nio.ByteBuffer;
public class TypingPingPacket extends AuthRequiredPacket {
public static final short TYPE = 0x41;
public static final int LENGTH = 0x029;
private long topicID;
private boolean typing;
public TypingPingPacket() {
super(TYPE, LENGTH);
}
public long getTopicID() {
return topicID;
}
public void setTopicID(long topicID) {
this.topicID = topicID;
}
public boolean isTyping() {
return typing;
}
public void setTyping(boolean typing) {
this.typing = typing;
}
@Override
public Packet readBytes(ByteBuffer buffer, int size) {
TypingPingPacket packet = new TypingPingPacket();
packet.setAuth(new AuthSecret(buffer));
packet.topicID = buffer.getLong();
packet.typing = buffer.get() > 0;
return packet;
}
@Override
public void writeBytes(ByteBuffer buffer) {
buffer.put(getAuth().getBytes());
buffer.putLong(topicID);
buffer.put(typing ? (byte)1 : 0);
}
}

View File

@ -4,9 +4,11 @@ import dev.wiing.gossip.lib.data.AuthSecret;
import dev.wiing.gossip.lib.models.SecretUser; import dev.wiing.gossip.lib.models.SecretUser;
import dev.wiing.gossip.lib.models.Topic; import dev.wiing.gossip.lib.models.Topic;
import dev.wiing.gossip.lib.models.User; import dev.wiing.gossip.lib.models.User;
import dev.wiing.gossip.server.customs.TypingTopic;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.*; import java.util.*;
@ -15,14 +17,14 @@ public class Database {
private static final Logger logger = LogManager.getLogger(Database.class); private static final Logger logger = LogManager.getLogger(Database.class);
private int userIdCounter = 1; private int channelIDCounter = 1;
private final Map<Long, User> users = Collections.synchronizedMap(new HashMap<>()); private final Map<Long, User> users = Collections.synchronizedMap(new HashMap<>());
private final Map<String, User> usersBySecret = Collections.synchronizedMap(new HashMap<>()); private final Map<String, User> usersBySecret = Collections.synchronizedMap(new HashMap<>());
private final Map<Long, Socket> userSockets = Collections.synchronizedMap(new HashMap<>()); private final Map<Long, Socket> userSockets = Collections.synchronizedMap(new HashMap<>());
private final Set<String> usedUsernames = Collections.synchronizedSet(new HashSet<>()); private final Set<String> usedUsernames = Collections.synchronizedSet(new HashSet<>());
private int topicIdCounter = 1; private final Map<Socket, User> connectedUsers = Collections.synchronizedMap(new HashMap<>());
private final Map<Long, Topic> topics = Collections.synchronizedMap(new HashMap<>()); private final Map<Long, TypingTopic> topics = Collections.synchronizedMap(new HashMap<>());
private Database() { private Database() {
@ -41,7 +43,7 @@ public class Database {
random.nextBytes(secretBytes); random.nextBytes(secretBytes);
AuthSecret secret = new AuthSecret(secretBytes); AuthSecret secret = new AuthSecret(secretBytes);
long userID = userIdCounter++; long userID = channelIDCounter++;
User user = new User(username, iconID, userID); User user = new User(username, iconID, userID);
@ -49,6 +51,7 @@ public class Database {
userSockets.put(userID, socket); userSockets.put(userID, socket);
usersBySecret.put(secret.getString(), user); usersBySecret.put(secret.getString(), user);
usedUsernames.add(username); usedUsernames.add(username);
connectedUsers.put(socket, user);
logger.info("User created: \"{}\" (#{})", user.getUsername(), user.getUserID()); logger.info("User created: \"{}\" (#{})", user.getUsername(), user.getUserID());
@ -79,12 +82,7 @@ public class Database {
} }
public User getUserOfSocket(Socket socket) { public User getUserOfSocket(Socket socket) {
return userSockets.entrySet().stream() return connectedUsers.get(socket);
.filter(entry -> entry.getValue().equals(socket))
.findFirst()
.map(Map.Entry::getKey)
.map(this::getUserByID)
.orElse(null);
} }
public void removeUser(User user) { public void removeUser(User user) {
@ -96,22 +94,43 @@ public class Database {
.findFirst() .findFirst()
.ifPresent(usersBySecret::remove); .ifPresent(usersBySecret::remove);
userSockets.remove(user.getUserID());
usedUsernames.remove(user.getUsername()); usedUsernames.remove(user.getUsername());
disconnectUser(user);
logger.info("User removed: \"{}\" (#{})", user.getUsername(), user.getUserID()); logger.info("User removed: \"{}\" (#{})", user.getUsername(), user.getUserID());
} }
public Topic createTopic(AuthSecret userSecret, String topicName, String topicDescription) { public void disconnectUser(User user) {
Socket socket = null;
if (userSockets.containsKey(user.getUserID())) {
socket = userSockets.remove(user.getUserID());
if (socket != null && !socket.isClosed()) {
try {
socket.close();
} catch (IOException ignored) {
}
}
}
if (socket != null) {
connectedUsers.remove(socket);
logger.info("User disconnected: \"{}\" (#{})", user.getUsername(), user.getUserID());
}
}
public TypingTopic createTopic(AuthSecret userSecret, String topicName, String topicDescription) {
if (!usersBySecret.containsKey(userSecret.getString())) return null; if (!usersBySecret.containsKey(userSecret.getString())) return null;
User user = usersBySecret.get(userSecret.getString()); User user = usersBySecret.get(userSecret.getString());
short colorHue = (short)Math.abs((new Random().nextInt(360))); short colorHue = (short)Math.abs((new Random().nextInt(360)));
Topic topic = new Topic( TypingTopic topic = new TypingTopic(
topicIdCounter++, channelIDCounter++,
topicName, topicName,
topicDescription, topicDescription,
user, user,
@ -125,17 +144,17 @@ public class Database {
return topic; return topic;
} }
public Topic getTopic(long topicID) { public TypingTopic getTopic(long topicID) {
return topics.getOrDefault(topicID, null); return topics.getOrDefault(topicID, null);
} }
public void removeTopic(long topicID) { public void removeTopic(long topicID) {
Topic topic = topics.remove(topicID); TypingTopic topic = topics.remove(topicID);
logger.info("Topic removed: \"{}\" (#{})", topic.getName(), topic.getId()); logger.info("Topic removed: \"{}\" (#{})", topic.getName(), topic.getId());
} }
public List<Topic> getAllTopicsReadOnly() { public List<TypingTopic> getAllTopicsReadOnly() {
return Collections.unmodifiableList(topics.values().stream().toList()); return topics.values().stream().toList();
} }
} }

View File

@ -3,13 +3,13 @@ package dev.wiing.gossip.server;
import dev.wiing.gossip.lib.data.LongData; import dev.wiing.gossip.lib.data.LongData;
import dev.wiing.gossip.lib.models.*; import dev.wiing.gossip.lib.models.*;
import dev.wiing.gossip.lib.packets.*; import dev.wiing.gossip.lib.packets.*;
import dev.wiing.gossip.server.customs.TypingTopic;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.net.SocketException; import java.net.SocketException;
import java.util.List;
public record UserSocket(Socket socket) implements Runnable { public record UserSocket(Socket socket) implements Runnable {
@ -72,6 +72,10 @@ public record UserSocket(Socket socket) implements Runnable {
case MessageFetchPacket.TYPE: case MessageFetchPacket.TYPE:
onFetchMessage((MessageFetchPacket) packet); onFetchMessage((MessageFetchPacket) packet);
break; break;
case TypingPingPacket.TYPE:
onTypingUser((TypingPingPacket) packet);
break;
} }
} catch (SocketException e) { } catch (SocketException e) {
loop = false; loop = false;
@ -82,7 +86,7 @@ public record UserSocket(Socket socket) implements Runnable {
// Connection lost // Connection lost
User user = Database.getInstance().getUserOfSocket(socket); User user = Database.getInstance().getUserOfSocket(socket);
Database.getInstance().removeUser(user); Database.getInstance().disconnectUser(user);
} }
private void onRegisterUser(RegisterRequestPacket packet) { private void onRegisterUser(RegisterRequestPacket packet) {
@ -118,6 +122,8 @@ public record UserSocket(Socket socket) implements Runnable {
for (User user : Database.getInstance().getUsers()) { for (User user : Database.getInstance().getUsers()) {
Socket socket = Database.getInstance().getUserSocket(user.getUserID()); Socket socket = Database.getInstance().getUserSocket(user.getUserID());
if (socket == null) continue;
try { try {
Globals.getPacketManager().writePacket(socket.getOutputStream(), added); Globals.getPacketManager().writePacket(socket.getOutputStream(), added);
} catch (IOException e) { } catch (IOException e) {
@ -162,6 +168,8 @@ public record UserSocket(Socket socket) implements Runnable {
for (User user : Database.getInstance().getUsers()) { for (User user : Database.getInstance().getUsers()) {
Socket socket = Database.getInstance().getUserSocket(user.getUserID()); Socket socket = Database.getInstance().getUserSocket(user.getUserID());
if (socket == null) continue;
try { try {
Globals.getPacketManager().writeAllPackets(socket.getOutputStream(), updatePacket, systemPacket); Globals.getPacketManager().writeAllPackets(socket.getOutputStream(), updatePacket, systemPacket);
} catch (IOException e) { } catch (IOException e) {
@ -259,6 +267,8 @@ public record UserSocket(Socket socket) implements Runnable {
for (User babbler : topic.getUsersReadOnly().values()) { for (User babbler : topic.getUsersReadOnly().values()) {
Socket userSocket = Database.getInstance().getUserSocket(babbler.getUserID()); Socket userSocket = Database.getInstance().getUserSocket(babbler.getUserID());
if (userSocket == null) continue;
try { try {
Globals.getPacketManager().writePacket(userSocket.getOutputStream(), messageCreated); Globals.getPacketManager().writePacket(userSocket.getOutputStream(), messageCreated);
} catch (IOException e) { } catch (IOException e) {
@ -306,6 +316,7 @@ public record UserSocket(Socket socket) implements Runnable {
resp.setMessageType(MessageDataPacket.MessageType.USER); resp.setMessageType(MessageDataPacket.MessageType.USER);
resp.setUserAuthorID(userMessage.getAuthor().getUserID()); resp.setUserAuthorID(userMessage.getAuthor().getUserID());
resp.setUserContents(userMessage.getContents()); resp.setUserContents(userMessage.getContents());
} else if (message instanceof SystemMessage systemMessage) { } else if (message instanceof SystemMessage systemMessage) {
resp.setMessageType(MessageDataPacket.MessageType.SYSTEM); resp.setMessageType(MessageDataPacket.MessageType.SYSTEM);
resp.setSystemType(systemMessage.getType()); resp.setSystemType(systemMessage.getType());
@ -321,4 +332,68 @@ public record UserSocket(Socket socket) implements Runnable {
} }
} }
private void onTypingUser(TypingPingPacket packet) {
User user = Database.getInstance().getUserBySecret(packet.getAuth());
if (user == null) return;
TypingTopic topic = Database.getInstance().getTopic(packet.getTopicID());
if (topic == null || !topic.hasUser(user)) return;
if (packet.isTyping()) {
topic.addTypingUser(user);
if (topic.getTypingExpiry().containsKey(user)) topic.getTypingExpiry().get(user).interrupt();
Thread thread = new Thread(() -> {
try {
Thread.sleep(20_000);
} catch (InterruptedException e) {
return;
}
topic.removeTypingUser(user);
announceTypingList(topic);
topic.getTypingExpiry().remove(user);
});
thread.start();
topic.getTypingExpiry().put(user, thread);
} else {
topic.removeTypingUser(user);
Thread thread = topic.getTypingExpiry().remove(user);
if (thread != null && thread.isAlive()) thread.interrupt();
}
info("User #{} typing ping on #{}", user.getUserID(), topic.getId());
announceTypingList(topic);
}
private static void announceTypingList(TypingTopic topic) {
TypingListUpdatePacket resp = new TypingListUpdatePacket();
resp.setTopicID(topic.getId());
resp.getTypingMembers().addAll(topic.getTypingUsersReadOnly().stream()
.map(typing -> new LongData().setValue(typing.getUserID()))
.toList());
for (User babbler : topic.getUsersReadOnly().values()) {
Socket userSocket = Database.getInstance().getUserSocket(babbler.getUserID());
if (userSocket == null) continue;
try {
Globals.getPacketManager().writePacket(userSocket.getOutputStream(), resp);
} catch (IOException e) {
logger.error(e);
}
}
}
} }

View File

@ -0,0 +1,22 @@
package dev.wiing.gossip.server.customs;
import dev.wiing.gossip.lib.models.Topic;
import dev.wiing.gossip.lib.models.User;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class TypingTopic extends Topic {
private final Map<User, Thread> typingExpiry = new ConcurrentHashMap<>();
public TypingTopic(long id, String name, String description, User host, short color) {
super(id, name, description, host, color);
}
public Map<User, Thread> getTypingExpiry() {
return typingExpiry;
}
}