using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Threading; using KfChatDotNetGui.Models; using KfChatDotNetGui.ViewModels; using KfChatDotNetWsClient; using KfChatDotNetWsClient.Models; using KfChatDotNetWsClient.Models.Events; using KfChatDotNetWsClient.Models.Json; using NLog; using Websocket.Client; namespace KfChatDotNetGui.Views { public partial class MainWindow : Window { private Logger _logger = LogManager.GetCurrentClassLogger(); // Using an empty config as we can update it later through the UpdateConfig method // Having this instance created early is handy for wiring up the events private ChatClient _chatClient = new(new ChatClientConfigModel()); private SettingsModel _settings = null!; private int _currentRoom; private ForumIdentityModel? _forumIdentity = null!; public MainWindow() { InitializeComponent(); _chatClient.OnMessages += OnMessages; _chatClient.OnUsersJoined += OnUsersJoined; _chatClient.OnUsersParted += OnUsersParted; _chatClient.OnWsReconnect += OnOnWsReconnect; _chatClient.OnWsDisconnection += OnOnWsDisconnection; _chatClient.OnDeleteMessages += OnDeleteMessages; _chatClient.OnFailedToJoinRoom += OnFailedToJoinRoom; } private void OnFailedToJoinRoom(object sender, string message) { Dispatcher.UIThread.InvokeAsync(() => { UpdateStatus($"Failed to join room, room ID {_currentRoom} is probably invalid"); }); } private void OnDeleteMessages(object sender, List messageIds) { Dispatcher.UIThread.InvokeAsync(() => { _logger.Info($"Received delete event for following message IDs: {string.Join(',', messageIds)}"); // Gotta make a copy of all the messages (annoyingly) as we'll be deleting stuff and .NET has a very obvious limitation there var messages = ((DataContext as MainWindowViewModel)!).Messages.ToList(); foreach (var message in messages) { foreach (var innerMessage in message.Messages.Where(m => messageIds.Contains(m.MessageId))) { // Remove the parent message thingy as otherwise it just shows as a blank item if (message.Messages.Count == 1) { _logger.Info("Removing parent message box"); ((DataContext as MainWindowViewModel)!).Messages.Remove(message); } // Go scavenging if there are multiple messages and we don't want to lose the lot else { ((DataContext as MainWindowViewModel)!) .Messages[((DataContext as MainWindowViewModel)!).Messages.IndexOf(message)].Messages .Remove(innerMessage); } _logger.Info($"Removed {innerMessage.MessageId}"); } } }); } private void OnOnWsDisconnection(object sender, DisconnectionInfo disconnectionInfo) { Dispatcher.UIThread.InvokeAsync(() => { UpdateStatus($"Disconnected from SneedChat due to {disconnectionInfo.Type}. Client should automatically attempt to reconnect"); }); } private void OnOnWsReconnect(object sender, ReconnectionInfo reconnectionInfo) { Dispatcher.UIThread.InvokeAsync(() => { UpdateStatus("Reconnected to SneedChat. Reason was " + reconnectionInfo.Type); ((DataContext as MainWindowViewModel)!).Messages.Clear(); ((DataContext as MainWindowViewModel)!).UserList.Clear(); }); _chatClient.JoinRoom(_currentRoom); } private void IdentitySettings_OnClick(object? sender, RoutedEventArgs e) { var context = new IdentitySettingsWindowViewModel(); if (File.Exists("settings.json")) { var settings = JsonSerializer.Deserialize(File.ReadAllText("settings.json")); context.WsUri = settings!.WsUri; context.XfSessionToken = settings.XfSessionToken; context.ReconnectTimeout = settings.ReconnectTimeout; context.AntiDdosPow = settings.AntiDdosPow; context.Username = settings.Username; } var identitySettingsWindow = new IdentitySettingsWindow { DataContext = context }; identitySettingsWindow.ShowDialog(this); identitySettingsWindow.Closed += (o, args) => { ReloadSettings(); }; } private void ExitMenuItem_OnClick(object? sender, RoutedEventArgs e) { Environment.Exit(0); } private async Task ConnectToSneedChat() { UpdateStatus("Connecting to SneedChat"); if (!File.Exists("settings.json")) { _logger.Error("Cannot find settings.json and therefore unable to connect to SneedChat, notifying the user through the status bar"); UpdateStatus("Unable to connect as client has not been configured (settings.json missing)"); return; } ReloadSettings(); UpdateStatus("Testing XenForo token validity"); ForumIdentityModel? forumIdentity; if (string.IsNullOrEmpty(_settings.Username)) { try { forumIdentity = (await Helpers.ForumIdentity.GetForumIdentity(_settings.XfSessionToken, new Uri($"https://{_settings.WsUri.Host}/test-chat"), _settings.AntiDdosPow)); } catch (Exception ex) { _logger.Error(ex); UpdateStatus("Failed to test XenForo token, caught exception " + ex.Message); return; } } else { forumIdentity = new ForumIdentityModel { Username = _settings.Username, Id = int.MaxValue }; } if (forumIdentity == null) { UpdateStatus("Failed to deserialize account info on SneedChat page"); return; } if (forumIdentity.Id == 0) { UpdateStatus("Token failed, SneedChat page returned Guest"); return; } UpdateStatus("Token works! It belongs to " + forumIdentity.Username); _forumIdentity = forumIdentity; ((DataContext as MainWindowViewModel)!).UserId = _forumIdentity.Id; var roomListControl = this.FindControl("RoomList"); RoomSettingsModel.RoomList initialRoom; if (roomListControl!.SelectedItem == null) { initialRoom = (DataContext as MainWindowViewModel)?.RoomList.First()!; } else { initialRoom = (RoomSettingsModel.RoomList) roomListControl.SelectedItem; } _chatClient.UpdateConfig(new ChatClientConfigModel { CookieDomain = _settings.WsUri.Host, ReconnectTimeout = _settings.ReconnectTimeout, WsUri = _settings.WsUri, XfSessionToken = _settings.XfSessionToken }); await _chatClient.StartWsClient(); _chatClient.JoinRoom(initialRoom!.Id); _currentRoom = initialRoom.Id; UpdateStatus("Connected!"); } private void OnUsersJoined(object sender, List users, UsersJsonModel jsonPayload) { Dispatcher.UIThread.InvokeAsync(() => { foreach (var user in users) { if (((DataContext as MainWindowViewModel)!).UserList.FirstOrDefault(x => x.Id == user.Id) != null) { _logger.Info($"{user.Username} ({user.Id}) is already in the list but has joined again. New tab? Ignoring!"); continue; } ((DataContext as MainWindowViewModel)!).UserList.Add(new MainWindowViewModel.UserListViewModel { Id = user.Id, Name = user.Username }); } UpdateUserTotalStatus(); }); } private void OnUsersParted(object sender, List userIds) { Dispatcher.UIThread.InvokeAsync(() => { foreach (var id in userIds) { var row = ((DataContext as MainWindowViewModel)!).UserList.FirstOrDefault(x => x.Id == id); if (row == null) { _logger.Info($"A user ({id}) who isn't in the list has parted, ignoring!"); continue; } ((DataContext as MainWindowViewModel)!).UserList.Remove(row); } UpdateUserTotalStatus(); }); } private void OnMessages(object sender, List messages, MessagesJsonModel jsonPayload) { Dispatcher.UIThread.InvokeAsync(() => { var previousMessage = ((DataContext as MainWindowViewModel)!).Messages.LastOrDefault(); if (previousMessage == null) { previousMessage = new MainWindowViewModel.MessageViewModel {AuthorId = -1}; } foreach (var message in messages) { _logger.Info("Received message, data payload next"); if (message.RoomId != _currentRoom) { _logger.Info($"Message {message.MessageId} belongs to another room (we're in {_currentRoom}, this one was for {message.RoomId}), ignoring."); continue; } if (message.MessageEditDate != null) { _logger.Info("Received an edit. Going to rewrite message if it already exists, " + "if it doesn't, nothing will happen as this would occur when loading historically modified messages."); foreach (var msg in ((DataContext as MainWindowViewModel)!).Messages) { foreach (var innerMsg in msg.Messages.Where(m => m.MessageId == message.MessageId)) { innerMsg.Message = WebUtility.HtmlDecode(message.MessageRaw); _logger.Info("Found the original message, text has been overwritten"); return; } } _logger.Info("Never ended up finding the original message so this was probably historical"); } if (previousMessage.AuthorId == message.Author.Id) { _logger.Info("Found a message from the same author, merging"); var lastMessage = ((DataContext as MainWindowViewModel)!).Messages.Last(); lastMessage.Messages.Add(new MainWindowViewModel.InnerMessageViewModel { Message = WebUtility.HtmlDecode(message.MessageRaw), MessageId = message.MessageId, OwnMessage = _forumIdentity.Username == message.Author.Username }); continue; } var viewMessage = new MainWindowViewModel.MessageViewModel { Author = message.Author.Username, Messages = new ObservableCollection { new() { Message = WebUtility.HtmlDecode(message.MessageRaw), MessageId = message.MessageId, OwnMessage = _forumIdentity.Username == message.Author.Username } }, PostedAt = message.MessageDate.LocalDateTime, AuthorId = message.Author.Id }; ((DataContext as MainWindowViewModel)!).Messages.Add(viewMessage); previousMessage = viewMessage; } var messagesControl = this.FindControl("ChatMessageList"); messagesControl!.ScrollIntoView(((DataContext as MainWindowViewModel)!).Messages.Last()); }); } private void UpdateStatus(string newStatus) { (DataContext as MainWindowViewModel)!.Status = newStatus; } private void ConnectMenuItem_OnClick(object? sender, RoutedEventArgs e) { Dispatcher.UIThread.Post(() => ConnectToSneedChat(), DispatcherPriority.Background); } private void RoomSettingsMenuItem_OnClick(object? sender, RoutedEventArgs e) { var context = new RoomSettingsWindowViewModel(); if (File.Exists("rooms.json")) { var settings = JsonSerializer.Deserialize(File.ReadAllText("rooms.json")); context.RoomList.Clear(); foreach (var room in settings.Rooms) { context.RoomList.Add(new RoomSettingsModel.RoomList { Id = room.Id, Name = room.Name }); } } var roomSettingsWindow = new RoomSettingsWindow { DataContext = context }; roomSettingsWindow.ShowDialog(this); roomSettingsWindow.Closed += (o, args) => { ReloadRoomList(); }; } private void ReloadSettings() { if (!File.Exists("settings.json")) { _logger.Error("Was asked to reload the settings but settings.json doesn't exist so I won't bother"); return; } var settings = JsonSerializer.Deserialize(File.ReadAllText("settings.json")); _settings = settings; } private void ReloadRoomList() { if (!File.Exists("rooms.json")) { _logger.Error("Was asked to reload the room list but rooms.json doesn't exist so I won't bother"); return; } var rooms = JsonSerializer.Deserialize(File.ReadAllText("rooms.json")); (DataContext as MainWindowViewModel)!.RoomList = rooms!.Rooms; } private void RoomList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (!_chatClient.IsConnected()) { UpdateStatus("Cannot join room as client is not connected"); return; } var roomListControl = this.FindControl("RoomList"); var room = (RoomSettingsModel.RoomList) roomListControl.SelectedItem!; // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract if (room == null) { _logger.Info("Got a selection change on room list with a null selected item. This seems to just happen sometimes, ignoring."); return; } UpdateStatus($"Connected! Changing to {room.Name}"); ((DataContext as MainWindowViewModel)!).Messages.Clear(); (DataContext as MainWindowViewModel)?.UserList.Clear(); _chatClient.JoinRoom(room.Id); _currentRoom = room.Id; } private MainWindowViewModel.MessageViewModel? GetCurrentlySelectedMessage() { var messagesControl = this.FindControl("ChatMessageList"); return messagesControl.SelectedItem as MainWindowViewModel.MessageViewModel; } private void NewChatMessage_OnKeyDown(object? sender, KeyEventArgs e) { if (e.Key is not (Key.Enter or Key.Return)) { return; } TrySendMessageFromTextBox(); } private void NewChatMessageSubmitButton_OnClick(object? sender, RoutedEventArgs e) { TrySendMessageFromTextBox(); } private void TrySendMessageFromTextBox() { if (!_chatClient.IsConnected()) { UpdateStatus("Cannot send a message while disconnected"); return; } var newChatMessage = this.FindControl("NewChatMessage"); _chatClient.SendMessage(newChatMessage.Text); newChatMessage.Clear(); } private void UpdateUserTotalStatus() { var userCount = (DataContext as MainWindowViewModel).UserList.Count; UpdateStatus($"Connected! {userCount} users in chat"); } private void MessageEditButton_OnClick(object? sender, RoutedEventArgs e) { _logger.Info("Edit button clicked for " + ((e.Source as Button).DataContext as MainWindowViewModel.InnerMessageViewModel).MessageId); } private void CopyButton_OnClick(object? sender, RoutedEventArgs e) { var message = (e.Source as Button).DataContext as MainWindowViewModel.InnerMessageViewModel; if (message == null) { _logger.Info("Caught a null when trying to access the inner message model instance for the purposes of copying"); return; } _logger.Info($"Copying {message.MessageId} to clipboard"); GetTopLevel(e.Source as Button)?.Clipboard?.SetTextAsync(message.Message).Wait(); } private void InnerMessageRow_OnPointerEnter(object? sender, PointerEventArgs e) { ((e.Source as ListBoxItem).DataContext as MainWindowViewModel.InnerMessageViewModel).IsHighlighted = true; } private void InnerMessageRow_OnPointerLeave(object? sender, PointerEventArgs e) { ((e.Source as ListBoxItem).DataContext as MainWindowViewModel.InnerMessageViewModel).IsHighlighted = false; } private void OuterMessageRow_OnPointerEnter(object? sender, PointerEventArgs e) { var children = ((e.Source as ListBox)!).GetLogicalChildren().Cast(); foreach (var child in children) { // Bit of a ghetto hack but it ensures that there's only ever one subscriber child.PointerEntered -= InnerMessageRow_OnPointerEnter; child.PointerExited -= InnerMessageRow_OnPointerLeave; child.PointerEntered += InnerMessageRow_OnPointerEnter; child.PointerExited += InnerMessageRow_OnPointerLeave; } } private void MessageDeleteButton_OnClick(object? sender, RoutedEventArgs e) { var newChatMessage = this.FindControl("NewChatMessage"); var messageId = ((e.Source as Button).DataContext as MainWindowViewModel.InnerMessageViewModel).MessageId; newChatMessage.Text = "/delete " + messageId; } private void AuthorNameButton_OnClick(object? sender, RoutedEventArgs e) { var message = (e.Source as Button).DataContext as MainWindowViewModel.MessageViewModel; var newChatMessage = this.FindControl("NewChatMessage"); newChatMessage.Text += $"@{message.Author}, "; newChatMessage.Focus(); newChatMessage.SelectionStart = newChatMessage.Text.Length; newChatMessage.SelectionEnd = newChatMessage.Text.Length; } } }