diff --git a/Decho/App.axaml.cs b/Decho/App.axaml.cs index 2c85eda..1ab8811 100644 --- a/Decho/App.axaml.cs +++ b/Decho/App.axaml.cs @@ -9,6 +9,8 @@ namespace Decho; public partial class App : Application { + private MainWindowViewModel? _viewModel; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -18,13 +20,18 @@ public partial class App : Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + _viewModel = new MainWindowViewModel(); + desktop.MainWindow = new MainWindow { - DataContext = new MainWindowViewModel(), + DataContext = _viewModel, }; + + _viewModel.SetMainWindow(desktop.MainWindow); + + desktop.Exit += (_, _) => _viewModel.Dispose(); } base.OnFrameworkInitializationCompleted(); } - } \ No newline at end of file diff --git a/Decho/Decho.csproj b/Decho/Decho.csproj index ba5ea85..831bf40 100644 --- a/Decho/Decho.csproj +++ b/Decho/Decho.csproj @@ -3,6 +3,7 @@ WinExe net10.0 enable + enable app.manifest true @@ -25,5 +26,15 @@ + + + + + + + + + + diff --git a/Decho/Models/ChannelModel.cs b/Decho/Models/ChannelModel.cs index dd72e12..22638e3 100644 --- a/Decho/Models/ChannelModel.cs +++ b/Decho/Models/ChannelModel.cs @@ -4,16 +4,27 @@ namespace Decho.Models; public sealed class ChannelModel { - public ChannelModel(string id, string name, ObservableCollection messages) + public ChannelModel( + string id, + string name, + ObservableCollection messages, + string? topic = null, + bool isPublic = true) { Id = id; Name = name; Messages = messages; + Topic = topic; + IsPublic = isPublic; } public string Id { get; } public string Name { get; } + public string? Topic { get; set; } + + public bool IsPublic { get; } + public ObservableCollection Messages { get; } } diff --git a/Decho/Models/MessageModel.cs b/Decho/Models/MessageModel.cs index 0822ba0..9d546fa 100644 --- a/Decho/Models/MessageModel.cs +++ b/Decho/Models/MessageModel.cs @@ -1,15 +1,31 @@ -using System; +using EchoHub.Core.Models; namespace Decho.Models; public sealed class MessageModel { - public MessageModel(string id, UserModel author, DateTimeOffset sentAt, string content) + public MessageModel( + string id, + UserModel author, + DateTimeOffset sentAt, + string content, + string channelName, + string? serverUrl = null, + MessageType type = MessageType.Text, + string? attachmentUrl = null, + string? attachmentFileName = null, + long? attachmentFileSize = null) { Id = id; Author = author; SentAt = sentAt; Content = content; + ChannelName = channelName; + ServerUrl = serverUrl; + Type = type; + AttachmentUrl = attachmentUrl; + AttachmentFileName = attachmentFileName; + AttachmentFileSize = attachmentFileSize; } public string Id { get; } @@ -19,4 +35,16 @@ public sealed class MessageModel public DateTimeOffset SentAt { get; } public string Content { get; } + + public string ChannelName { get; } + + public string? ServerUrl { get; } + + public MessageType Type { get; } + + public string? AttachmentUrl { get; } + + public string? AttachmentFileName { get; } + + public long? AttachmentFileSize { get; } } diff --git a/Decho/Models/ServerModel.cs b/Decho/Models/ServerModel.cs index 86509e2..3e5e4eb 100644 --- a/Decho/Models/ServerModel.cs +++ b/Decho/Models/ServerModel.cs @@ -4,16 +4,35 @@ namespace Decho.Models; public sealed class ServerModel { - public ServerModel(string id, string name, ObservableCollection channels) + public ServerModel( + string id, + string name, + ObservableCollection channels, + string serverUrl = "", + bool isConnected = false, + bool isConnecting = false, + string? connectedUser = null) { Id = id; Name = name; Channels = channels; + ServerUrl = serverUrl; + IsConnected = isConnected; + IsConnecting = isConnecting; + ConnectedUser = connectedUser; } public string Id { get; } public string Name { get; } + public string ServerUrl { get; set; } + + public bool IsConnected { get; set; } + + public bool IsConnecting { get; set; } + + public string? ConnectedUser { get; set; } + public ObservableCollection Channels { get; } } diff --git a/Decho/Models/UserModel.cs b/Decho/Models/UserModel.cs index 6d8f328..32a4387 100644 --- a/Decho/Models/UserModel.cs +++ b/Decho/Models/UserModel.cs @@ -1,14 +1,30 @@ +using EchoHub.Core.Models; + namespace Decho.Models; public sealed class UserModel { - public UserModel(string id, string displayName) + public UserModel( + string id, + string displayName, + string? nicknameColor = null, + UserStatus status = UserStatus.Online, + string? statusMessage = null) { Id = id; DisplayName = displayName; + NicknameColor = nicknameColor; + Status = status; + StatusMessage = statusMessage; } public string Id { get; } public string DisplayName { get; } + + public string? NicknameColor { get; set; } + + public UserStatus Status { get; set; } + + public string? StatusMessage { get; set; } } diff --git a/Decho/Services/ConnectionService.cs b/Decho/Services/ConnectionService.cs new file mode 100644 index 0000000..4dc03b4 --- /dev/null +++ b/Decho/Services/ConnectionService.cs @@ -0,0 +1,509 @@ +using System.Collections.ObjectModel; +using EchoHub.Client.Config; +using EchoHub.Client.Services; +using EchoHub.Client.Commands; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Decho.Models; + +namespace Decho.Services; + +public sealed class ConnectionService : IDisposable +{ + private readonly Dictionary _connections = new(StringComparer.OrdinalIgnoreCase); + + public event Action? ServerAdded; + public event Action? ServerRemoved; + public event Action? ServerStateChanged; + public event Action? ChannelAdded; + public event Action? ChannelRemoved; + public event Action? MessageReceived; + public event Action? UserJoined; + public event Action? UserLeft; + public event Action? ErrorOccurred; + + internal IReadOnlyDictionary Connections => _connections; + + internal ServerConnection? GetConnection(string serverUrl) => + _connections.TryGetValue(serverUrl, out var conn) ? conn : null; + + public async Task ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe) + { + var conn = new ConnectionManager(); + + var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult( + serverUrl, username, password, isRegister, rememberMe, null); + + ConnectResult result; + try + { + result = await conn.ConnectAsync(dialogResult, _ => { }); + } + catch + { + await conn.DisposeAsync(); + throw; + } + + var login = result.Login; + var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, + login.NicknameColor); + + var channels = new ObservableCollection(); + var serverModel = new ServerModel( + Guid.NewGuid().ToString("N"), + new Uri(serverUrl).Host, + channels, + serverUrl, + isConnected: true, + connectedUser: login.Username); + + var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel); + + foreach (var ch in result.Channels) + { + var channelModel = ChannelModelFromDto(ch); + channels.Add(channelModel); + } + + WireConnectionEvents(serverEntry, conn); + + _connections[serverUrl] = serverEntry; + SaveRefreshToken(serverUrl, rememberMe); + ServerAdded?.Invoke(serverModel); + + return serverModel; + } + + public async Task ConnectWithSavedTokenAsync(string serverUrl, string username, string refreshToken, bool rememberMe) + { + var conn = new ConnectionManager(); + + var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult( + serverUrl, username, "", false, rememberMe, refreshToken); + + ConnectResult result; + try + { + result = await conn.ConnectAsync(dialogResult, _ => { }); + } + catch + { + await conn.DisposeAsync(); + throw; + } + + var login = result.Login; + var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, login.NicknameColor); + + var channels = new ObservableCollection(); + var serverModel = new ServerModel( + Guid.NewGuid().ToString("N"), + new Uri(serverUrl).Host, + channels, + serverUrl, + isConnected: true, + connectedUser: login.Username); + + var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel); + + foreach (var ch in result.Channels) + { + var channelModel = ChannelModelFromDto(ch); + channels.Add(channelModel); + } + + WireConnectionEvents(serverEntry, conn); + + _connections[serverUrl] = serverEntry; + SaveRefreshToken(serverUrl, rememberMe); + ServerAdded?.Invoke(serverModel); + } + + private void SaveRefreshToken(string serverUrl, bool rememberMe) + { + if (!rememberMe) return; + if (!_connections.TryGetValue(serverUrl, out var entry)) return; + var token = entry.ApiClient.RefreshToken; + if (string.IsNullOrEmpty(token)) return; + + var config = ConfigManager.Load(); + var saved = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (saved is null) + { + saved = new SavedServer + { + Name = new Uri(serverUrl).Host, + Url = serverUrl, + Username = entry.User.Id, + RememberMe = true, + LastConnected = DateTimeOffset.Now, + }; + config.SavedServers.Add(saved); + } + + saved.RefreshToken = token; + saved.LastConnected = DateTimeOffset.Now; + ConfigManager.Save(config); + } + + public async Task DisconnectAsync(string serverUrl) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + + entry.Server.IsConnected = false; + entry.Server.IsConnecting = false; + ServerStateChanged?.Invoke(entry.Server); + + await entry.Manager.CleanupAsync(); + entry.ApiClient.Dispose(); + await entry.Manager.DisposeAsync(); + + _connections.Remove(serverUrl); + ServerRemoved?.Invoke(serverUrl); + } + + public async Task SendMessageAsync(string serverUrl, string channelName, string content) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + throw new InvalidOperationException("Not connected to server"); + + await entry.Manager.SendMessageAsync(channelName, content); + } + + public async Task> JoinChannelAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + throw new InvalidOperationException("Not connected to server"); + + if (entry.Manager.TrackChannel(channelName)) + { + var history = await entry.Manager.JoinChannelAsync(channelName); + return history.Select(m => MessageModelFromDto(m, entry)).ToList(); + } + + var existing = await entry.Manager.GetHistoryAsync(channelName); + return existing.Select(m => MessageModelFromDto(m, entry)).ToList(); + } + + public async Task LeaveChannelAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.Manager.LeaveChannelAsync(channelName); + } + + public async Task> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return []; + + var history = await entry.Manager.GetHistoryAsync(channelName, count, offset); + return history.Select(m => MessageModelFromDto(m, entry)).ToList(); + } + + public async Task> GetOnlineUsersAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return []; + return await entry.Manager.GetOnlineUsersAsync(channelName); + } + + public async Task UpdateStatusAsync(string serverUrl, UserStatus status, string? statusMessage) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.Manager.UpdateStatusAsync(status, statusMessage); + entry.User.Status = status; + entry.User.StatusMessage = statusMessage; + } + + public async Task CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return null; + return await entry.ApiClient.CreateChannelAsync(name, topic, isPublic); + } + + public async Task DeleteChannelAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.DeleteChannelAsync(channelName); + entry.Manager.UntrackChannel(channelName); + } + + public async Task KickUserAsync(string serverUrl, string username, string? reason) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.KickUserAsync(username, reason); + } + + public async Task BanUserAsync(string serverUrl, string username, string? reason) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.BanUserAsync(username, reason); + } + + public async Task UnbanUserAsync(string serverUrl, string username) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.UnbanUserAsync(username); + } + + public async Task MuteUserAsync(string serverUrl, string username, int? durationMinutes) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.MuteUserAsync(username, durationMinutes); + } + + public async Task UnmuteUserAsync(string serverUrl, string username) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.UnmuteUserAsync(username); + } + + public async Task AssignRoleAsync(string serverUrl, string username, ServerRole role) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.AssignRoleAsync(username, role); + } + + public async Task NukeChannelAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.NukeChannelAsync(channelName); + } + + public async Task UpdateProfileAsync(string serverUrl, string? displayName, string? bio, string? nickColor) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.UpdateProfileAsync(new UpdateProfileRequest(displayName, bio, nickColor)); + } + + public async Task SetAvatarAsync(string serverUrl, string target) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await AvatarHelper.UploadAsync(entry.ApiClient, target); + } + + public async Task SendUrlAsync(string serverUrl, string channelName, string url, string? size) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + await entry.ApiClient.SendUrlAsync(channelName, url, size); + } + + public async Task UploadFileAsync(string serverUrl, string channelName, string filePath, string? size) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + + await using var stream = File.OpenRead(filePath); + var fileName = Path.GetFileName(filePath); + await entry.ApiClient.UploadFileAsync(channelName, stream, fileName, size); + } + + public void UpdateChannelTopic(string serverUrl, string channelName, string? topic) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + + var channel = entry.Server.Channels.FirstOrDefault(c => + string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (channel is not null) + channel.Topic = topic; + } + + public void AddChannelToList(string serverUrl, ChannelDto channelDto) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + + var model = ChannelModelFromDto(channelDto); + entry.Server.Channels.Add(model); + ChannelAdded?.Invoke(serverUrl, model); + } + + public void RemoveChannelFromList(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return; + + var channel = entry.Server.Channels.FirstOrDefault(c => + string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (channel is not null) + { + entry.Server.Channels.Remove(channel); + ChannelRemoved?.Invoke(serverUrl, channelName); + } + } + + public ClientConfig LoadConfig() => ConfigManager.Load(); + + public void SaveConfig(ClientConfig config) => ConfigManager.Save(config); + + public void SaveServerToConfig(SavedServer server) => ConfigManager.SaveServer(server); + + public void RemoveServerFromConfig(string url) => ConfigManager.RemoveServer(url); + + public CommandHandler CreateCommandHandler() => new(); + + public bool IsCommand(string input) => input.StartsWith('/'); + + public string? GetRefreshToken(string serverUrl) + { + return _connections.TryGetValue(serverUrl, out var entry) + ? entry.ApiClient.RefreshToken + : null; + } + + public async Task DownloadAttachmentAsync(string serverUrl, string relativeUrl, string fileName) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return null; + try + { + return await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, fileName); + } + catch + { + return null; + } + } + + public async Task DownloadImageBytesAsync(string serverUrl, string relativeUrl) + { + if (!_connections.TryGetValue(serverUrl, out var entry)) + return null; + return await entry.ApiClient.DownloadBytesAsync(relativeUrl); + } + + public string? GetCurrentUsername(string serverUrl) + { + return _connections.TryGetValue(serverUrl, out var entry) + ? entry.User.DisplayName + : null; + } + + public void Dispose() + { + foreach (var (_, entry) in _connections) + { + entry.ApiClient.Dispose(); + } + _connections.Clear(); + } + + private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn) + { + conn.MessageReceived += message => + { + var msg = MessageModelFromDto(message, entry); + MessageReceived?.Invoke(entry.Server.ServerUrl, msg); + }; + + conn.UserJoined += (channelName, username, presence) => + { + UserJoined?.Invoke(entry.Server.ServerUrl, channelName, username); + }; + + conn.UserLeft += (channelName, username) => + { + UserLeft?.Invoke(entry.Server.ServerUrl, channelName); + }; + + conn.UserStatusChanged += presence => + { + entry.User.Status = presence.Status; + entry.User.StatusMessage = presence.StatusMessage; + }; + + conn.ChannelUpdated += channel => + { + var existing = entry.Server.Channels.FirstOrDefault(c => + string.Equals(c.Name, channel.Name, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + existing.Topic = channel.Topic; + } + }; + + conn.ForceDisconnected += reason => + { + entry.Server.IsConnected = false; + ServerStateChanged?.Invoke(entry.Server); + ErrorOccurred?.Invoke(entry.Server.ServerUrl, reason); + }; + + conn.Error += error => + { + ErrorOccurred?.Invoke(entry.Server.ServerUrl, error); + }; + + conn.ConnectionStatusChanged += status => + { + entry.Server.IsConnected = status == "Connected"; + entry.Server.IsConnecting = status is "Connecting..." or "Authenticating..."; + ServerStateChanged?.Invoke(entry.Server); + }; + } + + internal static ChannelModel ChannelModelFromDto(ChannelDto dto) + { + return new ChannelModel( + dto.Id.ToString(), + dto.Name, + new ObservableCollection(), + dto.Topic, + dto.IsPublic); + } + + internal static MessageModel MessageModelFromDto(MessageDto dto, ServerConnection entry) + { + var author = new UserModel( + dto.SenderUsername, + dto.SenderUsername, + dto.SenderNicknameColor); + + return new MessageModel( + dto.Id.ToString("N"), + author, + dto.SentAt, + dto.Content, + dto.ChannelName, + entry.Server.ServerUrl, + dto.Type, + dto.AttachmentUrl, + dto.AttachmentFileName, + dto.AttachmentFileSize); + } +} + +internal sealed class ServerConnection +{ + internal ConnectionManager Manager { get; } + public ApiClient ApiClient { get; } + public ServerModel Server { get; } + public UserModel User { get; } + + internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user) + { + Manager = manager; + ApiClient = apiClient; + Server = server; + User = user; + } +} \ No newline at end of file diff --git a/Decho/ViewModels/ChannelViewModel.cs b/Decho/ViewModels/ChannelViewModel.cs index f183dde..b9dfaf1 100644 --- a/Decho/ViewModels/ChannelViewModel.cs +++ b/Decho/ViewModels/ChannelViewModel.cs @@ -18,11 +18,40 @@ public sealed class ChannelViewModel : ViewModelBase public string Name => Model.Name; + public string? Topic + { + get => Model.Topic; + set + { + if (Model.Topic != value) + { + Model.Topic = value; + this.RaisePropertyChanged(); + this.RaisePropertyChanged(nameof(HasTopic)); + } + } + } + + public bool HasTopic => !string.IsNullOrWhiteSpace(Topic); + + public bool IsPublic => Model.IsPublic; + public ObservableCollection Messages { get; } + public void ClearMessages() + { + Model.Messages.Clear(); + Messages.Clear(); + } + public void AddMessage(MessageModel message) { Model.Messages.Add(message); Messages.Add(new MessageViewModel(message)); } -} + + public void AddMessageViewModel(MessageViewModel messageViewModel) + { + Messages.Add(messageViewModel); + } +} \ No newline at end of file diff --git a/Decho/ViewModels/ChatViewModel.cs b/Decho/ViewModels/ChatViewModel.cs index 74f7587..2d56766 100644 --- a/Decho/ViewModels/ChatViewModel.cs +++ b/Decho/ViewModels/ChatViewModel.cs @@ -1,4 +1,9 @@ +using System; using System.Collections.ObjectModel; +using System.Threading.Tasks; + +using EchoHub.Client.Commands; +using ReactiveUI; namespace Decho.ViewModels; @@ -6,10 +11,15 @@ public sealed class ChatViewModel : ViewModelBase { private ObservableCollection _messages = new(); private string _channelTitle = "Select a channel"; + private string? _channelTopic; + private bool _hasTopic; + private string _currentServerUrl = string.Empty; + private string _currentChannelName = string.Empty; public ChatViewModel() { Composer = new MessageComposerViewModel(); + Composer.CommandRequested += HandleCommandAsync; } public ObservableCollection Messages @@ -24,18 +34,76 @@ public sealed class ChatViewModel : ViewModelBase private set => this.RaiseAndSetIfChanged(ref _channelTitle, value); } + public string? ChannelTopic + { + get => _channelTopic; + set + { + this.RaiseAndSetIfChanged(ref _channelTopic, value); + this.RaisePropertyChanged(nameof(HasTopic)); + } + } + + public bool HasTopic + { + get => _hasTopic; + private set => this.RaiseAndSetIfChanged(ref _hasTopic, value); + } + public MessageComposerViewModel Composer { get; } - public void SetChannel(ChannelViewModel? channel) + public string CurrentServerUrl => _currentServerUrl; + public string CurrentChannelName => _currentChannelName; + + public event Func>? CommandRequested; + + public void SetChannel(ChannelViewModel? channel, string serverUrl = "") { if (channel is null) { Messages = new ObservableCollection(); ChannelTitle = "Select a channel"; + ChannelTopic = null; + HasTopic = false; + _currentChannelName = string.Empty; + _currentServerUrl = string.Empty; + Composer.SetServer(string.Empty); return; } Messages = channel.Messages; ChannelTitle = "#" + channel.Name; + ChannelTopic = channel.Topic; + HasTopic = channel.HasTopic; + _currentChannelName = channel.Name; + _currentServerUrl = serverUrl; + Composer.SetServer(serverUrl); } -} + + public void SetComposerCommandHandler(CommandHandler handler) + { + Composer.SetCommandHandler(handler); + } + + public void AddMessage(MessageViewModel message) + { + Messages.Add(message); + } + + public void ClearMessages() + { + Messages = new ObservableCollection(); + ChannelTitle = "Select a channel"; + ChannelTopic = null; + HasTopic = false; + } + + private async Task HandleCommandAsync(string commandText) + { + if (CommandRequested is not null) + { + return await CommandRequested(commandText); + } + return null; + } +} \ No newline at end of file diff --git a/Decho/ViewModels/MainWindowViewModel.cs b/Decho/ViewModels/MainWindowViewModel.cs index 49d65f7..c53ccf3 100644 --- a/Decho/ViewModels/MainWindowViewModel.cs +++ b/Decho/ViewModels/MainWindowViewModel.cs @@ -1,16 +1,35 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Decho.Models; +using Decho.Services; + +using EchoHub.Client.Commands; +using EchoHub.Client.Config; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + using ReactiveUI; namespace Decho.ViewModels; public sealed class MainWindowViewModel : ViewModelBase { - private readonly UserModel _currentUser; + private readonly ConnectionService _connectionService; + private readonly CommandHandler _commandHandler; + private string _statusText = "Ready"; + private bool _isConnected; + private Window? _mainWindow; public string Title { get; } = "Decho"; @@ -18,82 +37,842 @@ public sealed class MainWindowViewModel : ViewModelBase public ChatViewModel Chat { get; } + public string StatusText + { + get => _statusText; + set => this.RaiseAndSetIfChanged(ref _statusText, value); + } + + public bool IsConnected + { + get => _isConnected; + set => this.RaiseAndSetIfChanged(ref _isConnected, value); + } + + public ConnectionService ConnectionService => _connectionService; + + public ReactiveCommand AddServerCommand { get; } + public MainWindowViewModel() { - _currentUser = new UserModel("user-1", "You"); + _connectionService = new ConnectionService(); + _commandHandler = _connectionService.CreateCommandHandler(); - var servers = SeedServers(); - Sidebar = new SidebarViewModel(servers); + Sidebar = new SidebarViewModel(); Chat = new ChatViewModel(); - Sidebar.WhenAnyValue(x => x.SelectedChannel) - .Subscribe(channel => Chat.SetChannel(channel)); + AddServerCommand = ReactiveCommand.Create(AddServer); Chat.Composer.SendRequested += HandleSendRequested; + Chat.Composer.CommandRequested += HandleCommandAsync; + Chat.Composer.FileUploadRequested += HandleFileUploadRequested; - if (Sidebar.SelectedChannel is not null) - Chat.SetChannel(Sidebar.SelectedChannel); + WireCommandHandlerEvents(); + WireConnectionServiceEvents(); + + Sidebar.WhenAnyValue(x => x.SelectedChannel) + .Subscribe(channel => HandleChannelSelected(channel)); + + _ = InitializeSavedServersAsync(); } - private void HandleSendRequested(string text) + public void SetMainWindow(Window window) { - if (Sidebar.SelectedChannel is null) + _mainWindow = window; + } + + private void AddServer() + { + var placeholderServer = new ServerModel( + Guid.NewGuid().ToString("N"), + "New Server", + new ObservableCollection(), + isConnected: false); + + var serverVm = new ServerViewModel(placeholderServer); + serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm); + serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm); + + Sidebar.Servers.Add(serverVm); + StatusText = "Click Connect on the server to get started"; + } + + private void WireCommandHandlerEvents() + { + _commandHandler.OnSetStatus += async (status, message) => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.UpdateStatusAsync(serverUrl, status, message); + }; + + _commandHandler.OnSetTheme += themeName => + { + return Task.CompletedTask; + }; + + _commandHandler.OnJoinChannel += async channelName => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + + var channel = await _connectionService.JoinChannelAsync(serverUrl, channelName); + EnsureChannelInList(serverUrl, channelName); + var channelModel = FindChannel(serverUrl, channelName); + if (channelModel is not null) + { + var channelVm = Sidebar.GetServer(serverUrl)?.Channels + .FirstOrDefault(c => c.Name == channelName); + if (channelVm is not null) + { + foreach (var msg in channel) + channelVm.AddMessage(msg); + } + } + }; + + _commandHandler.OnLeaveChannel += async () => + { + var serverUrl = GetCurrentServerUrl(); + var channel = Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; + + if (channel == HubConstants.DefaultChannel) return; + await _connectionService.LeaveChannelAsync(serverUrl, channel); + }; + + _commandHandler.OnListUsers += async () => + { + var serverUrl = GetCurrentServerUrl(); + var channel = Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; + + var users = await _connectionService.GetOnlineUsersAsync(serverUrl, channel); + var userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username)); + StatusText = $"Online in #{channel}: {userList}"; + }; + + _commandHandler.OnSetTopic += async topic => + { + var serverUrl = GetCurrentServerUrl(); + var channel = Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; + + await _connectionService.UpdateProfileAsync(serverUrl, null, null, null); + _connectionService.UpdateChannelTopic(serverUrl, channel, topic); + Chat.ChannelTopic = topic; + }; + + _commandHandler.OnKickUser += async (username, reason) => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.KickUserAsync(serverUrl, username, reason); + }; + + _commandHandler.OnBanUser += async (username, reason) => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.BanUserAsync(serverUrl, username, reason); + }; + + _commandHandler.OnUnbanUser += async username => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.UnbanUserAsync(serverUrl, username); + }; + + _commandHandler.OnMuteUser += async (username, duration) => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.MuteUserAsync(serverUrl, username, duration); + }; + + _commandHandler.OnUnmuteUser += async username => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.UnmuteUserAsync(serverUrl, username); + }; + + _commandHandler.OnAssignRole += async (username, roleStr) => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + + var role = roleStr.ToLowerInvariant() switch + { + "admin" => ServerRole.Admin, + "mod" => ServerRole.Mod, + _ => ServerRole.Member, + }; + await _connectionService.AssignRoleAsync(serverUrl, username, role); + }; + + _commandHandler.OnNukeChannel += async () => + { + var serverUrl = GetCurrentServerUrl(); + var channel = Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; + await _connectionService.NukeChannelAsync(serverUrl, channel); + }; + + _commandHandler.OnTestSound += () => Task.CompletedTask; + + _commandHandler.OnQuit += () => + { + if (_mainWindow is not null) + Avalonia.Threading.Dispatcher.UIThread.Post(() => _mainWindow.Close()); + return Task.CompletedTask; + }; + + _commandHandler.OnHelp += () => Task.CompletedTask; + + _commandHandler.OnSendFile += async (target, size) => + { + var serverUrl = GetCurrentServerUrl(); + var channel = Chat.CurrentChannelName; + if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; + + try + { + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) + { + await _connectionService.SendUrlAsync(serverUrl, channel, target, size); + } + else + { + await _connectionService.UploadFileAsync(serverUrl, channel, target, size); + } + } + catch (Exception ex) + { + StatusText = $"Send failed: {ex.Message}"; + } + }; + + _commandHandler.OnSetNick += async displayName => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.UpdateProfileAsync(serverUrl, displayName, null, null); + }; + + _commandHandler.OnSetColor += async color => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.UpdateProfileAsync(serverUrl, null, null, color); + }; + + _commandHandler.OnSetAvatar += async target => + { + var serverUrl = GetCurrentServerUrl(); + if (string.IsNullOrEmpty(serverUrl)) return; + await _connectionService.SetAvatarAsync(serverUrl, target); + }; + + _commandHandler.OnOpenProfile += async username => + { + StatusText = $"Profile: {username ?? "self"}"; + }; + + _commandHandler.OnOpenServers += () => + { + var config = _connectionService.LoadConfig(); + var servers = string.Join("\n", config.SavedServers.Select(s => + $"{s.Name} ({s.Url}) - {s.Username ?? "?"}")); + StatusText = servers; + return Task.CompletedTask; + }; + } + + private void WireConnectionServiceEvents() + { + _connectionService.ServerAdded += server => + { + var serverVm = new ServerViewModel(server); + serverVm.ConnectRequested += () => HandleServerConnectRequested(serverVm); + serverVm.DisconnectRequested += () => HandleServerDisconnectRequested(serverVm); + serverVm.WhenAnyValue(s => s.SelectedChannel) + .Where(channel => channel is not null) + .Subscribe(channel => Sidebar.SelectedChannel = channel!); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var existing = Sidebar.GetServer(server.ServerUrl); + if (existing is not null) + Sidebar.Servers.Remove(existing); + Sidebar.Servers.Add(serverVm); + StatusText = $"Connected to {server.Name}"; + IsConnected = true; + }); + }; + + _connectionService.ServerRemoved += serverUrl => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Sidebar.RemoveServer(serverUrl); + if (Sidebar.Servers.Count == 0) + { + IsConnected = false; + Chat.ClearMessages(); + StatusText = "Ready"; + } + }); + }; + + _connectionService.ServerStateChanged += server => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var serverVm = Sidebar.GetServer(server.ServerUrl); + if (serverVm is not null) + { + serverVm.SyncFromModel(); + if (!server.IsConnected) + StatusText = $"Disconnected from {server.Name}"; + } + }); + }; + + _connectionService.MessageReceived += (serverUrl, message) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + var channelVm = FindChannelViewModel(serverUrl, message.ChannelName); + if (channelVm is not null) + { + channelVm.AddMessage(message); + } + }); + }; + + _connectionService.ChannelAdded += (serverUrl, channel) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + EnsureChannelInList(serverUrl, channel.Name); + }); + }; + + _connectionService.ErrorOccurred += (serverUrl, error) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + StatusText = $"Error: {error}"; + }); + }; + } + + private async Task HandleCommandAsync(string commandText) + { + var result = await _commandHandler.HandleAsync(commandText); + if (result.Message is not null && !result.IsError) + { + Chat.AddMessage(new MessageViewModel(new MessageModel( + Guid.NewGuid().ToString("N"), + new UserModel("system", "System"), + DateTimeOffset.Now, + result.Message, + Chat.CurrentChannelName, + Chat.CurrentServerUrl))); + } + return result.Message; + } + + private void HandleSendRequested(string serverUrl, string text) + { + if (string.IsNullOrEmpty(Chat.CurrentChannelName)) return; - var message = new MessageModel( - Guid.NewGuid().ToString("N"), - _currentUser, - DateTimeOffset.Now, - text); + if (_commandHandler.IsCommand(text)) + { + _ = HandleCommandAsync(text); + return; + } - Sidebar.SelectedChannel.AddMessage(message); + Task.Run(async () => + { + try + { + await _connectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text); + } + catch (Exception ex) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + StatusText = $"Send failed: {ex.Message}"; + }); + } + }); } - private static IReadOnlyList SeedServers() + private void HandleFileUploadRequested(string serverUrl, string filePath) { - var alex = new UserModel("user-2", "Alex"); - var sam = new UserModel("user-3", "Sam"); + if (string.IsNullOrEmpty(Chat.CurrentChannelName)) return; - var general = new ChannelModel( - "channel-1", - "general", - new ObservableCollection + Task.Run(async () => + { + try { - new("message-1", alex, DateTimeOffset.Now.AddMinutes(-30), "This is a message."), - new("message-2", sam, DateTimeOffset.Now.AddMinutes(-25), "Another message for the channel."), - new("message-3", alex, DateTimeOffset.Now.AddMinutes(-10), "We should test the \nnew layout."), + await _connectionService.UploadFileAsync(serverUrl, Chat.CurrentChannelName, filePath, null); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + StatusText = "File uploaded"); + } + catch (Exception ex) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + StatusText = $"Upload failed: {ex.Message}"); + } + }); + } + + private void HandleChannelSelected(ChannelViewModel? channel) + { + if (channel is null) + { + Chat.SetChannel(null); + return; + } + + var serverUrl = FindServerUrlForChannel(channel); + Chat.SetChannel(channel, serverUrl); + + if (!string.IsNullOrEmpty(serverUrl)) + { + var commandHandler = _connectionService.CreateCommandHandler(); + Chat.SetComposerCommandHandler(commandHandler); + + Task.Run(async () => + { + try + { + var history = await _connectionService.JoinChannelAsync(serverUrl, channel.Name); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + channel.ClearMessages(); + foreach (var msg in history) + channel.AddMessage(msg); + }); + } + catch + { + // Channel might not be available + } + }); + } + } + + private async Task InitializeSavedServersAsync() + { + var config = _connectionService.LoadConfig(); + var connectTasks = new List(); + + foreach (var saved in config.SavedServers) + { + var placeholderServer = new ServerModel( + Guid.NewGuid().ToString("N"), + saved.Name, + new ObservableCollection(), + saved.Url, + isConnected: false); + + var serverVm = new ServerViewModel(placeholderServer); + serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm); + serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm); + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Sidebar.Servers.Add(serverVm); }); - var random = new ChannelModel( - "channel-2", - "random", - new ObservableCollection + if (!string.IsNullOrEmpty(saved.RefreshToken) && saved.RememberMe) { - new("message-4", sam, DateTimeOffset.Now.AddMinutes(-5), @"Random chat keeps the vibe light. -rwerwerw -rw -er -wer -w -r -ztr5z56j7u5"), - }); + connectTasks.Add(AutoConnectSavedServer(serverVm, saved)); + } + } - var music = new ChannelModel( - "channel-3", - "music", - new ObservableCollection()); + await Task.WhenAll(connectTasks); + } - var server = new ServerModel( - "echo.voidcube.cloud", - "echo.voidcube.cloud", - new ObservableCollection { general, random }); + private async Task AutoConnectSavedServer(ServerViewModel serverVm, SavedServer saved) + { + if (string.IsNullOrEmpty(saved.Username) || string.IsNullOrEmpty(saved.RefreshToken)) + return; - var anotherServer = new ServerModel( - "echo.stone-red.net", - "echo.stone-red.net", - new ObservableCollection() { general, music}); + try + { + serverVm.IsConnecting = true; + await _connectionService.ConnectWithSavedTokenAsync( + saved.Url, saved.Username, saved.RefreshToken, saved.RememberMe); + } + catch + { + serverVm.IsConnecting = false; + } + } - return new[] { server, anotherServer }; + private static async Task ShowMessageBox(Window owner, string title, string message) + { + var msgBox = new Window + { + Title = title, + Width = 400, + Height = 180, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + }; + + var stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) }; + stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }); + var okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; + stack.Children.Add(okBtn); + msgBox.Content = stack; + + var tcs = new TaskCompletionSource(); + okBtn.Click += (_, _) => { msgBox.Close(); tcs.TrySetResult(); }; + msgBox.Closed += (_, _) => tcs.TrySetResult(); + + await msgBox.ShowDialog(owner); + await tcs.Task; + } + + private async Task HandleServerConnectRequested(ServerViewModel serverVm) + { + // Show connect dialog + if (_mainWindow is null) return; + + var config = _connectionService.LoadConfig(); + var prefill = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, serverVm.ServerUrl, StringComparison.OrdinalIgnoreCase)); + var dialog = new ConnectDialog(config.SavedServers, prefill); + + var result = await dialog.ShowAsync(_mainWindow); + if (result is null) return; + + try + { + serverVm.IsConnecting = true; + StatusText = "Connecting..."; + + if (result.IsSavedSession && result.SavedRefreshToken is not null) + { + await _connectionService.ConnectWithSavedTokenAsync( + result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe); + } + else + { + await _connectionService.ConnectAsync( + result.ServerUrl, result.Username, result.Password, result.IsRegister, result.RememberMe); + } + + // Remove the placeholder and let the real server added event handle it + Sidebar.RemoveServer(serverVm.ServerUrl); + + // Save to config with refresh token + var refreshToken = _connectionService.GetRefreshToken(result.ServerUrl); + var savedServer = new SavedServer + { + Name = new Uri(result.ServerUrl).Host, + Url = result.ServerUrl, + Username = result.Username, + RefreshToken = result.RememberMe ? refreshToken : null, + RememberMe = result.RememberMe, + LastConnected = DateTimeOffset.Now, + }; + _connectionService.SaveServerToConfig(savedServer); + } + catch (Exception ex) + { + StatusText = $"Connection failed: {ex.Message}"; + serverVm.IsConnecting = false; + } + } + + private async Task HandleServerDisconnectRequested(ServerViewModel serverVm) + { + try + { + await _connectionService.DisconnectAsync(serverVm.ServerUrl); + } + catch (Exception ex) + { + StatusText = $"Disconnect error: {ex.Message}"; + } + } + + private void EnsureChannelInList(string serverUrl, string channelName) + { + var serverVm = Sidebar.GetServer(serverUrl); + if (serverVm is null) return; + + if (!serverVm.Channels.Any(c => c.Name == channelName)) + { + var channelModel = new ChannelModel( + Guid.NewGuid().ToString("N"), + channelName, + new System.Collections.ObjectModel.ObservableCollection()); + var channelVm = new ChannelViewModel(channelModel); + serverVm.Channels.Add(channelVm); + } + } + + private ChannelModel? FindChannel(string serverUrl, string channelName) + { + var serverVm = Sidebar.GetServer(serverUrl); + return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName)?.Model; + } + + private ChannelViewModel? FindChannelViewModel(string serverUrl, string channelName) + { + var serverVm = Sidebar.GetServer(serverUrl); + return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName); + } + + private string FindServerUrlForChannel(ChannelViewModel channel) + { + foreach (var server in Sidebar.Servers) + { + if (server.Channels.Contains(channel)) + return server.ServerUrl; + } + return string.Empty; + } + + private string GetCurrentServerUrl() + { + return Chat.CurrentServerUrl; + } + + public void Dispose() + { + _connectionService.Dispose(); } } + +public sealed class ConnectDialogResult +{ + public string ServerUrl { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool IsRegister { get; set; } + public bool RememberMe { get; set; } + public bool IsSavedSession { get; set; } + public string? SavedRefreshToken { get; set; } +} + +public sealed class ConnectDialog +{ + private readonly List _savedServers; + private readonly SavedServer? _prefill; + + public ConnectDialog(List savedServers, SavedServer? prefill = null) + { + _savedServers = savedServers; + _prefill = prefill; + } + + public async Task ShowAsync(Window owner) + { + var dialog = new Window + { + Title = "Connect to Server", + Width = 450, + Height = 380, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + }; + + var stack = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(15) }; + + var urlLabel = new TextBlock { Text = "Server URL:" }; + var urlBox = new TextBox { Watermark = "http://localhost:5000", Text = "http://localhost:5000" }; + stack.Children.Add(urlLabel); + stack.Children.Add(urlBox); + + var userLabel = new TextBlock { Text = "Username:" }; + var userBox = new TextBox { Watermark = "username" }; + stack.Children.Add(userLabel); + stack.Children.Add(userBox); + + var passLabel = new TextBlock { Text = "Password:" }; + var passBox = new TextBox { Watermark = "password", PasswordChar = '*' }; + stack.Children.Add(passLabel); + stack.Children.Add(passBox); + + var rememberMe = new CheckBox { Content = "Remember me", IsChecked = true }; + stack.Children.Add(rememberMe); + + var buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 }; + + var loginBtn = new Button { Content = "Login", Width = 100 }; + var registerBtn = new Button { Content = "Register", Width = 100 }; + var cancelBtn = new Button { Content = "Cancel", Width = 100 }; + buttonPanel.Children.Add(loginBtn); + buttonPanel.Children.Add(registerBtn); + buttonPanel.Children.Add(cancelBtn); + stack.Children.Add(buttonPanel); + + ListBox? serversList = null; + if (_savedServers.Count > 0) + { + var sep = new TextBlock { Text = "--- Saved Servers ---", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; + stack.Children.Add(sep); + + serversList = new ListBox { Height = 100 }; + serversList.ItemsSource = _savedServers.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"}").ToList(); + stack.Children.Add(serversList); + + serversList.SelectionChanged += (_, _) => + { + var idx = serversList.SelectedIndex; + if (idx >= 0 && idx < _savedServers.Count) + { + var s = _savedServers[idx]; + urlBox.Text = s.Url; + userBox.Text = s.Username ?? ""; + rememberMe.IsChecked = s.RememberMe; + if (!string.IsNullOrEmpty(s.RefreshToken)) + passBox.Text = ""; + } + }; + } + + if (_prefill is not null) + { + urlBox.Text = _prefill.Url; + userBox.Text = _prefill.Username ?? ""; + var idx = _savedServers.FindIndex(s => + string.Equals(s.Url, _prefill.Url, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0 && idx < _savedServers.Count && serversList is not null) + serversList.SelectedIndex = idx; + } + + dialog.Content = new ScrollViewer { Content = stack }; + + var tcs = new TaskCompletionSource(); + ConnectDialogResult? result = null; + + loginBtn.Click += async (_, _) => + { + var url = urlBox.Text?.Trim() ?? ""; + var user = userBox.Text?.Trim() ?? ""; + var pass = passBox.Text ?? ""; + + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user)) + { + await ShowMessageBox(owner, "Validation", "Server URL and username are required."); + return; + } + + // Check for saved session + var saved = _savedServers.FirstOrDefault(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase) && + string.Equals(s.Username, user, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(s.RefreshToken)); + + if (string.IsNullOrEmpty(pass) && saved is not null) + { + result = new ConnectDialogResult + { + ServerUrl = url, + Username = user, + IsSavedSession = true, + SavedRefreshToken = saved.RefreshToken, + RememberMe = saved.RememberMe, + }; + } + else + { + if (string.IsNullOrEmpty(pass)) + { + await ShowMessageBox(owner, "Validation", "Password is required."); + return; + } + + result = new ConnectDialogResult + { + ServerUrl = url, + Username = user, + Password = pass, + IsRegister = false, + RememberMe = rememberMe.IsChecked ?? false, + }; + } + + dialog.Close(); + tcs.TrySetResult(result); + }; + + registerBtn.Click += (_, _) => + { + var url = urlBox.Text?.Trim() ?? ""; + var user = userBox.Text?.Trim() ?? ""; + var pass = passBox.Text ?? ""; + + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + { + _ = ShowMessageBox(owner, "Validation", "Server URL, username, and password are required."); + return; + } + + result = new ConnectDialogResult + { + ServerUrl = url, + Username = user, + Password = pass, + IsRegister = true, + RememberMe = rememberMe.IsChecked ?? false, + }; + + dialog.Close(); + tcs.TrySetResult(result); + }; + + cancelBtn.Click += (_, _) => + { + dialog.Close(); + tcs.TrySetResult(null); + }; + + dialog.Closed += (_, _) => tcs.TrySetResult(result); + + if (owner is not null) + await dialog.ShowDialog(owner); + else + dialog.Show(); + + return await tcs.Task; + } + + private static async Task ShowMessageBox(Window owner, string title, string message) + { + var msgBox = new Window + { + Title = title, + Width = 350, + Height = 150, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false, + }; + + var stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) }; + stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }); + var okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; + stack.Children.Add(okBtn); + msgBox.Content = stack; + + var tcs = new TaskCompletionSource(); + okBtn.Click += (_, _) => { msgBox.Close(); tcs.TrySetResult(); }; + msgBox.Closed += (_, _) => tcs.TrySetResult(); + + await msgBox.ShowDialog(owner); + await tcs.Task; + } +} \ No newline at end of file diff --git a/Decho/ViewModels/MessageComposerViewModel.cs b/Decho/ViewModels/MessageComposerViewModel.cs index e298535..fe1942b 100644 --- a/Decho/ViewModels/MessageComposerViewModel.cs +++ b/Decho/ViewModels/MessageComposerViewModel.cs @@ -1,7 +1,9 @@ using System; using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; +using EchoHub.Client.Commands; using ReactiveUI; namespace Decho.ViewModels; @@ -9,6 +11,8 @@ namespace Decho.ViewModels; public sealed class MessageComposerViewModel : ViewModelBase { private string _draft = string.Empty; + private CommandHandler? _commandHandler; + private string _serverUrl = string.Empty; public MessageComposerViewModel() { @@ -22,9 +26,34 @@ public sealed class MessageComposerViewModel : ViewModelBase set => this.RaiseAndSetIfChanged(ref _draft, value); } + public string ServerUrl => _serverUrl; + public ReactiveCommand SendCommand { get; } - public event Action? SendRequested; + public bool HasCommandHandler => _commandHandler is not null; + + public event Action? SendRequested; + public event Func>? CommandRequested; + public event Action? FileUploadRequested; + + public void RequestFileUpload(string filePath) + { + if (!string.IsNullOrEmpty(_serverUrl)) + FileUploadRequested?.Invoke(_serverUrl, filePath); + } + + public void SetServer(string serverUrl) + { + _serverUrl = serverUrl; + } + + public void SetCommandHandler(CommandHandler handler) + { + _commandHandler = handler; + this.RaisePropertyChanged(nameof(HasCommandHandler)); + } + + public bool IsCommand(string input) => _commandHandler?.IsCommand(input) ?? input.StartsWith('/'); private void Send() { @@ -33,6 +62,14 @@ public sealed class MessageComposerViewModel : ViewModelBase return; Draft = string.Empty; - SendRequested?.Invoke(text); + + if (_commandHandler is not null && _commandHandler.IsCommand(text)) + { + CommandRequested?.Invoke(text); + } + else + { + SendRequested?.Invoke(_serverUrl, text); + } } -} +} \ No newline at end of file diff --git a/Decho/ViewModels/MessageViewModel.cs b/Decho/ViewModels/MessageViewModel.cs index fa4a635..8160d41 100644 --- a/Decho/ViewModels/MessageViewModel.cs +++ b/Decho/ViewModels/MessageViewModel.cs @@ -1,6 +1,7 @@ -using Decho.Models; - using System; +using Avalonia.Media; +using EchoHub.Core.Models; +using Decho.Models; namespace Decho.ViewModels; @@ -15,13 +16,41 @@ public sealed class MessageViewModel : ViewModelBase public string AuthorName => Model.Author.DisplayName; + public IBrush? AuthorColor + { + get + { + var color = Model.Author.NicknameColor; + if (string.IsNullOrEmpty(color)) return null; + try { return new SolidColorBrush(Avalonia.Media.Color.Parse(color)); } + catch { return null; } + } + } + public string Content => Model.Content; + public string? ServerUrl => Model.ServerUrl; + + public MessageType Type => Model.Type; + + public bool HasAttachment => Model.AttachmentUrl is not null; + + public string? AttachmentFileName => Model.AttachmentFileName; + + public string? AttachmentUrl => Model.AttachmentUrl; + + public bool IsImage => Type == MessageType.Image; + + public bool ShowContent => !IsImage && !IsFile; + + public bool IsAudio => Type == MessageType.Audio; + + public bool IsFile => Type == MessageType.File; + public string TimeText { get { - // If today, show time only; otherwise, show date and time. var now = DateTimeOffset.Now; if (Model.SentAt.Date == now.Date) @@ -29,7 +58,7 @@ public sealed class MessageViewModel : ViewModelBase return Model.SentAt.ToLocalTime().ToString("t"); } - return now.ToString("g"); + return Model.SentAt.ToLocalTime().ToString("g"); } } -} +} \ No newline at end of file diff --git a/Decho/ViewModels/ServerViewModel.cs b/Decho/ViewModels/ServerViewModel.cs index d0be4b7..a92157c 100644 --- a/Decho/ViewModels/ServerViewModel.cs +++ b/Decho/ViewModels/ServerViewModel.cs @@ -1,7 +1,9 @@ using System.Collections.ObjectModel; using System.Linq; +using System.Reactive; using Decho.Models; +using ReactiveUI; namespace Decho.ViewModels; @@ -9,20 +11,49 @@ public sealed class ServerViewModel : ViewModelBase { private bool _isExpanded = true; private ChannelViewModel? _selectedChannel; + private bool _isConnected; + private bool _isConnecting; + private string? _connectedUser; public ServerViewModel(ServerModel model) { Model = model; Channels = new ObservableCollection( model.Channels.Select(channel => new ChannelViewModel(channel))); + + _isConnected = model.IsConnected; + _isConnecting = model.IsConnecting; + _connectedUser = model.ConnectedUser; + + ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync); + DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync); } + public event Func? ConnectRequested + { + add => _connectRequested = (Func?)Delegate.Combine(_connectRequested, value); + remove => _connectRequested = (Func?)Delegate.Remove(_connectRequested, value); + } + private Func? _connectRequested; + + public event Func? DisconnectRequested + { + add => _disconnectRequested = (Func?)Delegate.Combine(_disconnectRequested, value); + remove => _disconnectRequested = (Func?)Delegate.Remove(_disconnectRequested, value); + } + private Func? _disconnectRequested; + public ServerModel Model { get; } public string Name => Model.Name; + public string ServerUrl => Model.ServerUrl; + public ObservableCollection Channels { get; } + public ReactiveCommand ConnectCommand { get; } + public ReactiveCommand DisconnectCommand { get; } + public ChannelViewModel? SelectedChannel { get => _selectedChannel; @@ -34,4 +65,60 @@ public sealed class ServerViewModel : ViewModelBase get => _isExpanded; set => this.RaiseAndSetIfChanged(ref _isExpanded, value); } -} + + public bool IsConnected + { + get => _isConnected; + set + { + this.RaiseAndSetIfChanged(ref _isConnected, value); + this.RaisePropertyChanged(nameof(ConnectionStatusText)); + this.RaisePropertyChanged(nameof(ConnectionStatusColor)); + this.RaisePropertyChanged(nameof(ShowConnectionControls)); + } + } + + public bool IsConnecting + { + get => _isConnecting; + set + { + this.RaiseAndSetIfChanged(ref _isConnecting, value); + this.RaisePropertyChanged(nameof(ConnectionStatusText)); + this.RaisePropertyChanged(nameof(ShowConnectionControls)); + } + } + + public string? ConnectedUser + { + get => _connectedUser; + set => this.RaiseAndSetIfChanged(ref _connectedUser, value); + } + + public string ConnectionStatusText => IsConnecting ? "Connecting..." : + IsConnected ? $"Connected as {ConnectedUser}" : "Disconnected"; + + public string ConnectionStatusColor => IsConnected ? "Green" : IsConnecting ? "Orange" : "Gray"; + + public bool ShowConnectionControls => !IsConnected && !IsConnecting; + + private async Task ConnectAsync() + { + if (_connectRequested is not null) + await _connectRequested(); + } + + private async Task DisconnectAsync() + { + if (_disconnectRequested is not null) + await _disconnectRequested(); + } + + public void SyncFromModel() + { + IsConnected = Model.IsConnected; + IsConnecting = Model.IsConnecting; + ConnectedUser = Model.ConnectedUser; + IsExpanded = true; + } +} \ No newline at end of file diff --git a/Decho/ViewModels/SidebarViewModel.cs b/Decho/ViewModels/SidebarViewModel.cs index a75e7a7..34fc476 100644 --- a/Decho/ViewModels/SidebarViewModel.cs +++ b/Decho/ViewModels/SidebarViewModel.cs @@ -13,18 +13,9 @@ public sealed class SidebarViewModel : ViewModelBase { private ChannelViewModel? _selectedChannel; - public SidebarViewModel(IEnumerable servers) + public SidebarViewModel() { - Servers = new ObservableCollection( - servers.Select(server => new ServerViewModel(server))); - SelectedChannel = Servers.FirstOrDefault()?.Channels.FirstOrDefault(); - - foreach (var server in Servers) - { - server.WhenAnyValue(s => s.SelectedChannel) - .Where(channel => channel is not null) - .Subscribe(new Action(channel => SelectedChannel = channel!)); - } + Servers = new ObservableCollection(); } public ObservableCollection Servers { get; } @@ -56,4 +47,33 @@ public sealed class SidebarViewModel : ViewModelBase } } } -} + + public void AddServer(ServerModel model) + { + var vm = new ServerViewModel(model); + vm.WhenAnyValue(s => s.SelectedChannel) + .Where(channel => channel is not null) + .Subscribe(channel => SelectedChannel = channel!); + + Servers.Add(vm); + } + + public void RemoveServer(string serverUrl) + { + var server = Servers.FirstOrDefault(s => + string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (server is not null) + { + if (SelectedChannel is not null && server.Channels.Contains(SelectedChannel)) + SelectedChannel = null; + + Servers.Remove(server); + } + } + + public ServerViewModel? GetServer(string serverUrl) + { + return Servers.FirstOrDefault(s => + string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/Decho/Views/MainWindow.axaml b/Decho/Views/MainWindow.axaml index 62cf761..8305c43 100644 --- a/Decho/Views/MainWindow.axaml +++ b/Decho/Views/MainWindow.axaml @@ -1,33 +1,48 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vm="using:Decho.ViewModels" + xmlns:views="using:Decho.Views" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:i="https://github.com/projektanker/icons.avalonia" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + x:Class="Decho.Views.MainWindow" + x:DataType="vm:MainWindowViewModel" + Icon="/Assets/avalonia-logo.ico" + Width="1100" Height="600" + Title="Decho"> - - - - + + + - - - - - - + + + + +