From c0570970de067b33780d4f6f1d4f62447da113ca Mon Sep 17 00:00:00 2001
From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:30:59 +0200
Subject: [PATCH] Basic echo implementation
---
Decho/App.axaml.cs | 11 +-
Decho/Decho.csproj | 11 +
Decho/Models/ChannelModel.cs | 13 +-
Decho/Models/MessageModel.cs | 32 +-
Decho/Models/ServerModel.cs | 21 +-
Decho/Models/UserModel.cs | 18 +-
Decho/Services/ConnectionService.cs | 509 +++++++++++
Decho/ViewModels/ChannelViewModel.cs | 31 +-
Decho/ViewModels/ChatViewModel.cs | 72 +-
Decho/ViewModels/MainWindowViewModel.cs | 885 +++++++++++++++++--
Decho/ViewModels/MessageComposerViewModel.cs | 43 +-
Decho/ViewModels/MessageViewModel.cs | 39 +-
Decho/ViewModels/ServerViewModel.cs | 89 +-
Decho/ViewModels/SidebarViewModel.cs | 44 +-
Decho/Views/MainWindow.axaml | 75 +-
Decho/Views/MessageComposerView.axaml | 19 +-
Decho/Views/MessageComposerView.axaml.cs | 50 ++
Decho/Views/MessageItemView.axaml | 27 +-
Decho/Views/MessageItemView.axaml.cs | 83 ++
Decho/Views/MessageListView.axaml | 2 +-
Decho/Views/MessageListView.axaml.cs | 85 ++
Decho/Views/SidebarView.axaml | 83 +-
22 files changed, 2084 insertions(+), 158 deletions(-)
create mode 100644 Decho/Services/ConnectionService.cs
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">
-
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Decho/Views/MessageComposerView.axaml b/Decho/Views/MessageComposerView.axaml
index d80738a..4e1f81f 100644
--- a/Decho/Views/MessageComposerView.axaml
+++ b/Decho/Views/MessageComposerView.axaml
@@ -5,24 +5,25 @@
x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel">
-
-
+
-
+
-
+
\ No newline at end of file
diff --git a/Decho/Views/MessageComposerView.axaml.cs b/Decho/Views/MessageComposerView.axaml.cs
index dc80d72..28d7321 100644
--- a/Decho/Views/MessageComposerView.axaml.cs
+++ b/Decho/Views/MessageComposerView.axaml.cs
@@ -1,4 +1,9 @@
+using System.Linq;
using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Decho.ViewModels;
namespace Decho.Views;
@@ -7,5 +12,50 @@ public partial class MessageComposerView : UserControl
public MessageComposerView()
{
InitializeComponent();
+ AddHandler(DragDrop.DragOverEvent, OnDragOver);
+ AddHandler(DragDrop.DropEvent, OnDrop);
+ DragDrop.SetAllowDrop(this, true);
+ }
+
+ private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MessageComposerViewModel vm) return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider is null) return;
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ AllowMultiple = false,
+ Title = "Select a file to upload",
+ });
+
+ var file = files?.FirstOrDefault();
+ if (file?.TryGetLocalPath() is string path)
+ {
+ vm.RequestFileUpload(path);
+ }
+ }
+
+ private void OnDragOver(object? sender, DragEventArgs e)
+ {
+#pragma warning disable CS0618
+ if (e.Data.Contains(DataFormats.Files))
+#pragma warning restore CS0618
+ e.DragEffects = DragDropEffects.Copy;
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ if (DataContext is not MessageComposerViewModel vm) return;
+
+#pragma warning disable CS0618
+ var paths = e.Data.GetFiles()?
+ .Select(f => f.TryGetLocalPath())
+ .FirstOrDefault(p => p is not null);
+#pragma warning restore CS0618
+
+ if (paths is string path)
+ vm.RequestFileUpload(path);
}
}
diff --git a/Decho/Views/MessageItemView.axaml b/Decho/Views/MessageItemView.axaml
index 37cab46..4fa0405 100644
--- a/Decho/Views/MessageItemView.axaml
+++ b/Decho/Views/MessageItemView.axaml
@@ -3,9 +3,8 @@
xmlns:vm="using:Decho.ViewModels"
x:Class="Decho.Views.MessageItemView"
x:DataType="vm:MessageViewModel">
-
-
+
+ FontWeight="Bold"
+ Foreground="{Binding AuthorColor}" />
+ TextWrapping="Wrap"
+ IsVisible="{Binding ShowContent}" />
+
+
+
+
+
diff --git a/Decho/Views/MessageItemView.axaml.cs b/Decho/Views/MessageItemView.axaml.cs
index 15cd9fd..1b84f65 100644
--- a/Decho/Views/MessageItemView.axaml.cs
+++ b/Decho/Views/MessageItemView.axaml.cs
@@ -1,11 +1,94 @@
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using Decho.ViewModels;
namespace Decho.Views;
public partial class MessageItemView : UserControl
{
+ private CancellationTokenSource? _loadCts;
+ private bool _pendingLoad;
+
public MessageItemView()
{
InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ Loaded += OnLoaded;
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs args)
+ {
+ _loadCts?.Cancel();
+ _loadCts = new CancellationTokenSource();
+ _pendingLoad = DataContext is MessageViewModel msg && msg.IsImage && msg.AttachmentUrl is not null;
+ }
+
+ private void OnLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (!_pendingLoad) return;
+ _pendingLoad = false;
+ var msg = (MessageViewModel)DataContext!;
+ _ = LoadImageAsync(msg, _loadCts!.Token);
+ }
+
+ private async Task LoadImageAsync(MessageViewModel msg, CancellationToken ct)
+ {
+ try
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.DataContext is not MainWindowViewModel mainVm) return;
+
+ var bytes = await mainVm.ConnectionService.DownloadImageBytesAsync(
+ msg.ServerUrl ?? "", msg.AttachmentUrl!);
+
+ ct.ThrowIfCancellationRequested();
+ if (bytes is null || bytes.Length == 0) return;
+
+ using var stream = new MemoryStream(bytes);
+ var bitmap = new Bitmap(stream);
+ ct.ThrowIfCancellationRequested();
+ var image = this.FindControl("MessageImage");
+ if (image is not null)
+ image.Source = bitmap;
+ }
+ catch
+ {
+ // Image failed to load — filename is shown as fallback
+ }
+ }
+
+ private async void OnDownloadClicked(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MessageViewModel msg) return;
+ if (msg.AttachmentUrl is null) return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider is null) return;
+
+ var mainVm = topLevel.DataContext as MainWindowViewModel;
+ if (mainVm is null) return;
+
+ var tempPath = await mainVm.ConnectionService.DownloadAttachmentAsync(
+ msg.ServerUrl ?? "", msg.AttachmentUrl, msg.AttachmentFileName ?? "download");
+
+ if (tempPath is null) return;
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ SuggestedFileName = msg.AttachmentFileName ?? "download",
+ });
+
+ if (file?.TryGetLocalPath() is string savePath)
+ {
+ File.Copy(tempPath, savePath, overwrite: true);
+ }
+
+ try { File.Delete(tempPath); }
+ catch { }
}
}
diff --git a/Decho/Views/MessageListView.axaml b/Decho/Views/MessageListView.axaml
index 23005c0..bee22f7 100644
--- a/Decho/Views/MessageListView.axaml
+++ b/Decho/Views/MessageListView.axaml
@@ -4,7 +4,7 @@
xmlns:views="using:Decho.Views"
x:Class="Decho.Views.MessageListView"
x:DataType="vm:ChatViewModel">
-
+
diff --git a/Decho/Views/MessageListView.axaml.cs b/Decho/Views/MessageListView.axaml.cs
index 7bdac3e..bdc97f0 100644
--- a/Decho/Views/MessageListView.axaml.cs
+++ b/Decho/Views/MessageListView.axaml.cs
@@ -1,11 +1,96 @@
+using System.Collections.Specialized;
using Avalonia.Controls;
+using Avalonia.Threading;
+using Decho.ViewModels;
namespace Decho.Views;
public partial class MessageListView : UserControl
{
+ private ChatViewModel? _chat;
+ private bool _hasPendingScroll;
+ private bool _wasAtBottom;
+
public MessageListView()
{
InitializeComponent();
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs args)
+ {
+ if (_chat is not null)
+ {
+ _chat.Messages.CollectionChanged -= OnMessagesChanged;
+ _chat.PropertyChanged -= OnViewModelPropertyChanged;
+ }
+
+ var scroll = this.FindControl("MessageScrollViewer");
+ if (scroll is not null)
+ scroll.ScrollChanged -= OnScrollChanged;
+
+ _chat = DataContext as ChatViewModel;
+ if (_chat is not null)
+ {
+ _chat.Messages.CollectionChanged += OnMessagesChanged;
+ _chat.PropertyChanged += OnViewModelPropertyChanged;
+ if (scroll is not null)
+ scroll.ScrollChanged += OnScrollChanged;
+ }
+ }
+
+ private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
+ {
+ if (e.ExtentDelta.Y > 0 && _wasAtBottom)
+ {
+ var scroll = (ScrollViewer)sender!;
+ Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
+ }
+
+ var scrollViewer = (ScrollViewer)sender!;
+ _wasAtBottom = scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - 30;
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ChatViewModel.Messages))
+ {
+ _chat!.Messages.CollectionChanged += OnMessagesChanged;
+ ScrollToBottom();
+ }
+ }
+
+ private void OnMessagesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Add)
+ {
+ TryAutoScroll();
+ }
+ }
+
+ private void TryAutoScroll()
+ {
+ var scroll = this.FindControl("MessageScrollViewer");
+ if (scroll is null) return;
+
+ _wasAtBottom = scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 30;
+ if (_wasAtBottom && !_hasPendingScroll)
+ {
+ _hasPendingScroll = true;
+ Dispatcher.UIThread.Post(() =>
+ {
+ scroll.ScrollToEnd();
+ _hasPendingScroll = false;
+ }, DispatcherPriority.Background);
+ }
+ }
+
+ private void ScrollToBottom()
+ {
+ var scroll = this.FindControl("MessageScrollViewer");
+ if (scroll is null) return;
+
+ _wasAtBottom = true;
+ Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
}
}
diff --git a/Decho/Views/SidebarView.axaml b/Decho/Views/SidebarView.axaml
index 4d47ea4..94d9120 100644
--- a/Decho/Views/SidebarView.axaml
+++ b/Decho/Views/SidebarView.axaml
@@ -1,30 +1,55 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:vm="using:Decho.ViewModels"
+ xmlns:views="using:Decho.Views"
+ x:Class="Decho.Views.SidebarView"
+ x:DataType="vm:SidebarViewModel">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file