Basic echo implementation

This commit is contained in:
Stone_Red
2026-07-15 19:31:08 +02:00
parent 63c4e9b640
commit c0570970de
22 changed files with 2084 additions and 158 deletions
+30 -1
View File
@@ -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<MessageViewModel> 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);
}
}
+70 -2
View File
@@ -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<MessageViewModel> _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<MessageViewModel> 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<string, Task<string?>>? CommandRequested;
public void SetChannel(ChannelViewModel? channel, string serverUrl = "")
{
if (channel is null)
{
Messages = new ObservableCollection<MessageViewModel>();
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<MessageViewModel>();
ChannelTitle = "Select a channel";
ChannelTopic = null;
HasTopic = false;
}
private async Task<string?> HandleCommandAsync(string commandText)
{
if (CommandRequested is not null)
{
return await CommandRequested(commandText);
}
return null;
}
}
+832 -53
View File
@@ -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<Unit, Unit> 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<ChannelModel>(),
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<string?> 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<ServerModel> 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<MessageModel>
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<Task>();
foreach (var saved in config.SavedServers)
{
var placeholderServer = new ServerModel(
Guid.NewGuid().ToString("N"),
saved.Name,
new ObservableCollection<ChannelModel>(),
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<MessageModel>
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<MessageModel>());
await Task.WhenAll(connectTasks);
}
var server = new ServerModel(
"echo.voidcube.cloud",
"echo.voidcube.cloud",
new ObservableCollection<ChannelModel> { 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<ChannelModel>() { 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<MessageModel>());
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<SavedServer> _savedServers;
private readonly SavedServer? _prefill;
public ConnectDialog(List<SavedServer> savedServers, SavedServer? prefill = null)
{
_savedServers = savedServers;
_prefill = prefill;
}
public async Task<ConnectDialogResult?> 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?>();
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;
}
}
+40 -3
View File
@@ -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<Unit, Unit> SendCommand { get; }
public event Action<string>? SendRequested;
public bool HasCommandHandler => _commandHandler is not null;
public event Action<string, string>? SendRequested;
public event Func<string, Task<string?>>? CommandRequested;
public event Action<string, string>? 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);
}
}
}
}
+34 -5
View File
@@ -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");
}
}
}
}
+88 -1
View File
@@ -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<ChannelViewModel>(
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<Task>? ConnectRequested
{
add => _connectRequested = (Func<Task>?)Delegate.Combine(_connectRequested, value);
remove => _connectRequested = (Func<Task>?)Delegate.Remove(_connectRequested, value);
}
private Func<Task>? _connectRequested;
public event Func<Task>? DisconnectRequested
{
add => _disconnectRequested = (Func<Task>?)Delegate.Combine(_disconnectRequested, value);
remove => _disconnectRequested = (Func<Task>?)Delegate.Remove(_disconnectRequested, value);
}
private Func<Task>? _disconnectRequested;
public ServerModel Model { get; }
public string Name => Model.Name;
public string ServerUrl => Model.ServerUrl;
public ObservableCollection<ChannelViewModel> Channels { get; }
public ReactiveCommand<Unit, Unit> ConnectCommand { get; }
public ReactiveCommand<Unit, Unit> 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;
}
}
+32 -12
View File
@@ -13,18 +13,9 @@ public sealed class SidebarViewModel : ViewModelBase
{
private ChannelViewModel? _selectedChannel;
public SidebarViewModel(IEnumerable<ServerModel> servers)
public SidebarViewModel()
{
Servers = new ObservableCollection<ServerViewModel>(
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<ChannelViewModel?>(channel => SelectedChannel = channel!));
}
Servers = new ObservableCollection<ServerViewModel>();
}
public ObservableCollection<ServerViewModel> 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));
}
}