Move source code so /src

This commit is contained in:
Stone_Red
2026-07-15 20:58:53 +02:00
parent 58e21feafb
commit c1daa966fc
38 changed files with 2 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="Decho/Decho.csproj" />
</Solution>
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Decho.App"
xmlns:local="using:Decho"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<RomzetronTheme />
</Application.Styles>
</Application>
+37
View File
@@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Decho.ViewModels;
using Decho.Views;
namespace Decho;
public partial class App : Application
{
private MainWindowViewModel? _viewModel;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
_viewModel = new MainWindowViewModel();
desktop.MainWindow = new MainWindow
{
DataContext = _viewModel,
};
_viewModel.SetMainWindow(desktop.MainWindow);
desktop.Exit += (_, _) => _viewModel.Dispose();
}
base.OnFrameworkInitializationCompleted();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+8
View File
@@ -0,0 +1,8 @@
<Project>
<Import Project="..\EchoHub\src\Directory.Build.props" Condition="Exists('..\EchoHub\src\Directory.Build.props')" />
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Decho</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
+42
View File
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.12">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.6.2" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Romzetron.Avalonia" Version="11.3.4" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EchoHub\src\EchoHub.Client\EchoHub.Client.csproj">
<AdditionalProperties>DirectoryBuildPropsPath=$(MSBuildProjectDirectory)\Decho.Build.props</AdditionalProperties>
</ProjectReference>
<ProjectReference Include="..\EchoHub\src\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
using System.Collections.ObjectModel;
namespace Decho.Models;
public sealed class ChannelModel(string id, string name, ObservableCollection<MessageModel> messages, string? topic = null, bool isPublic = true)
{
public string Id { get; } = id;
public string Name { get; } = name;
public string? Topic { get; set; } = topic;
public bool IsPublic { get; } = isPublic;
public ObservableCollection<MessageModel> Messages { get; } = messages;
}
+26
View File
@@ -0,0 +1,26 @@
using EchoHub.Core.Models;
namespace Decho.Models;
public sealed class 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)
{
public string Id { get; } = id;
public UserModel Author { get; } = author;
public DateTimeOffset SentAt { get; } = sentAt;
public string Content { get; } = content;
public string ChannelName { get; } = channelName;
public string? ServerUrl { get; } = serverUrl;
public MessageType Type { get; } = type;
public string? AttachmentUrl { get; } = attachmentUrl;
public string? AttachmentFileName { get; } = attachmentFileName;
public long? AttachmentFileSize { get; } = attachmentFileSize;
}
+20
View File
@@ -0,0 +1,20 @@
using System.Collections.ObjectModel;
namespace Decho.Models;
public sealed class ServerModel(string id, string name, ObservableCollection<ChannelModel> channels, string serverUrl = "", bool isConnected = false, bool isConnecting = false, string? connectedUser = null)
{
public string Id { get; } = id;
public string Name { get; } = name;
public string ServerUrl { get; set; } = serverUrl;
public bool IsConnected { get; set; } = isConnected;
public bool IsConnecting { get; set; } = isConnecting;
public string? ConnectedUser { get; set; } = connectedUser;
public ObservableCollection<ChannelModel> Channels { get; } = channels;
}
+16
View File
@@ -0,0 +1,16 @@
using EchoHub.Core.Models;
namespace Decho.Models;
public sealed class UserModel(string id, string displayName, string? nicknameColor = null, UserStatus status = UserStatus.Online, string? statusMessage = null)
{
public string Id { get; } = id;
public string DisplayName { get; } = displayName;
public string? NicknameColor { get; set; } = nicknameColor;
public UserStatus Status { get; set; } = status;
public string? StatusMessage { get; set; } = statusMessage;
}
+35
View File
@@ -0,0 +1,35 @@
global using ReactiveUI;
global using ReactiveUI.Avalonia;
using Avalonia;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
namespace Decho;
internal static class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
_ = BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
{
_ = IconProvider.Current
.Register<FontAwesomeIconProvider>();
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}
}
+605
View File
@@ -0,0 +1,605 @@
using Decho.Models;
using EchoHub.Client.Commands;
using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using System.Collections.ObjectModel;
namespace Decho.Services;
public sealed class ConnectionService : IDisposable
{
public event Action<ServerModel>? ServerAdded;
public event Action<string>? ServerRemoved;
public event Action<ServerModel>? ServerStateChanged;
public event Action<string, ChannelModel>? ChannelAdded;
public event Action<string, string>? ChannelRemoved;
public event Action<string, MessageModel>? MessageReceived;
public event Action<string, string, string?>? UserJoined;
public event Action<string, string>? UserLeft;
public event Action<string, string>? ErrorOccurred;
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections;
public async Task<ServerModel> ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe)
{
ConnectionManager conn = new ConnectionManager();
ConnectDialogResult 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;
}
LoginResponse login = result.Login;
UserModel userModel = new UserModel(login.Username, login.DisplayName ?? login.Username,
login.NicknameColor);
ObservableCollection<ChannelModel> channels = [];
ServerModel serverModel = new ServerModel(
Guid.NewGuid().ToString("N"),
new Uri(serverUrl).Host,
channels,
serverUrl,
isConnected: true,
connectedUser: login.Username);
ServerConnection serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
foreach (ChannelDto ch in result.Channels)
{
ChannelModel 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)
{
ConnectionManager conn = new ConnectionManager();
ConnectDialogResult 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;
}
LoginResponse login = result.Login;
UserModel userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, login.NicknameColor);
ObservableCollection<ChannelModel> channels = [];
ServerModel serverModel = new ServerModel(
Guid.NewGuid().ToString("N"),
new Uri(serverUrl).Host,
channels,
serverUrl,
isConnected: true,
connectedUser: login.Username);
ServerConnection serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
foreach (ChannelDto ch in result.Channels)
{
ChannelModel channelModel = ChannelModelFromDto(ch);
channels.Add(channelModel);
}
WireConnectionEvents(serverEntry, conn);
_connections[serverUrl] = serverEntry;
SaveRefreshToken(serverUrl, rememberMe);
ServerAdded?.Invoke(serverModel);
}
public async Task DisconnectAsync(string serverUrl)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? 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 ServerConnection? entry))
{
throw new InvalidOperationException("Not connected to server");
}
await entry.Manager.SendMessageAsync(channelName, content);
}
public async Task<List<MessageModel>> JoinChannelAsync(string serverUrl, string channelName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
throw new InvalidOperationException("Not connected to server");
}
if (entry.Manager.TrackChannel(channelName))
{
List<MessageDto> history = await entry.Manager.JoinChannelAsync(channelName);
return history.Select(m => MessageModelFromDto(m, entry)).ToList();
}
List<MessageDto> 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 ServerConnection? entry))
{
return;
}
await entry.Manager.LeaveChannelAsync(channelName);
}
public async Task<List<MessageModel>> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return [];
}
List<MessageDto> history = await entry.Manager.GetHistoryAsync(channelName, count, offset);
return history.Select(m => MessageModelFromDto(m, entry)).ToList();
}
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string serverUrl, string channelName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return [];
}
return await entry.Manager.GetOnlineUsersAsync(channelName);
}
public async Task UpdateStatusAsync(string serverUrl, UserStatus status, string? statusMessage)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.Manager.UpdateStatusAsync(status, statusMessage);
entry.User.Status = status;
entry.User.StatusMessage = statusMessage;
}
public async Task<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return null;
}
return await entry.ApiClient.CreateChannelAsync(name, topic, isPublic);
}
public async Task DeleteChannelAsync(string serverUrl, string channelName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? 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 ServerConnection? entry))
{
return;
}
await entry.ApiClient.KickUserAsync(username, reason);
}
public async Task BanUserAsync(string serverUrl, string username, string? reason)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.BanUserAsync(username, reason);
}
public async Task UnbanUserAsync(string serverUrl, string username)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.UnbanUserAsync(username);
}
public async Task MuteUserAsync(string serverUrl, string username, int? durationMinutes)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.MuteUserAsync(username, durationMinutes);
}
public async Task UnmuteUserAsync(string serverUrl, string username)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.UnmuteUserAsync(username);
}
public async Task AssignRoleAsync(string serverUrl, string username, ServerRole role)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.AssignRoleAsync(username, role);
}
public async Task NukeChannelAsync(string serverUrl, string channelName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
await entry.ApiClient.NukeChannelAsync(channelName);
}
public async Task UpdateProfileAsync(string serverUrl, string? displayName, string? bio, string? nickColor)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
_ = await entry.ApiClient.UpdateProfileAsync(new UpdateProfileRequest(displayName, bio, nickColor));
}
public async Task SetAvatarAsync(string serverUrl, string target)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? 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 ServerConnection? 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 ServerConnection? entry))
{
return;
}
await using FileStream stream = File.OpenRead(filePath);
string 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 ServerConnection? entry))
{
return;
}
ChannelModel? channel = entry.Server.Channels.FirstOrDefault(c =>
string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase));
_ = channel?.Topic = topic;
}
public void AddChannelToList(string serverUrl, ChannelDto channelDto)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
ChannelModel model = ChannelModelFromDto(channelDto);
entry.Server.Channels.Add(model);
ChannelAdded?.Invoke(serverUrl, model);
}
public void RemoveChannelFromList(string serverUrl, string channelName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
ChannelModel? 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 string? GetRefreshToken(string serverUrl)
{
return _connections.TryGetValue(serverUrl, out ServerConnection? entry)
? entry.ApiClient.RefreshToken
: null;
}
public async Task<string?> DownloadAttachmentAsync(string serverUrl, string relativeUrl, string fileName)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return null;
}
try
{
return await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, fileName);
}
catch
{
return null;
}
}
public async Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string relativeUrl)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return null;
}
try
{
string? tempPath = await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, "image");
if (tempPath is null)
{
return null;
}
byte[] bytes = await File.ReadAllBytesAsync(tempPath);
try
{
File.Delete(tempPath);
}
catch
{
// Ignore if deletion fails
}
return bytes;
}
catch
{
return null;
}
}
public string? GetCurrentUsername(string serverUrl)
{
return _connections.TryGetValue(serverUrl, out ServerConnection? entry)
? entry.User.DisplayName
: null;
}
public void Dispose()
{
foreach ((string _, ServerConnection? entry) in _connections)
{
entry.ApiClient.Dispose();
}
_connections.Clear();
}
internal static ChannelModel ChannelModelFromDto(ChannelDto dto)
{
return new ChannelModel(
dto.Id.ToString(),
dto.Name,
[],
dto.Topic,
dto.IsPublic);
}
internal static MessageModel MessageModelFromDto(MessageDto dto, ServerConnection entry)
{
UserModel 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 ServerConnection? GetConnection(string serverUrl)
{
return _connections.TryGetValue(serverUrl, out ServerConnection? conn) ? conn : null;
}
private void SaveRefreshToken(string serverUrl, bool rememberMe)
{
if (!rememberMe)
{
return;
}
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return;
}
string? token = entry.ApiClient.RefreshToken;
if (string.IsNullOrEmpty(token))
{
return;
}
ClientConfig config = ConfigManager.Load();
SavedServer? 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);
}
private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn)
{
conn.MessageReceived += message =>
{
MessageModel 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 =>
{
ChannelModel? existing = entry.Server.Channels.FirstOrDefault(c =>
string.Equals(c.Name, channel.Name, StringComparison.OrdinalIgnoreCase));
_ = 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 sealed class ServerConnection
{
public ApiClient ApiClient { get; }
public ServerModel Server { get; }
public UserModel User { get; }
internal ConnectionManager Manager { get; }
internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user)
{
Manager = manager;
ApiClient = apiClient;
Server = server;
User = user;
}
}
+40
View File
@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Decho.ViewModels;
using System.Diagnostics.CodeAnalysis;
namespace Decho;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
{
return null;
}
string name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
Type? type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
+45
View File
@@ -0,0 +1,45 @@
using Decho.Models;
using System.Collections.ObjectModel;
namespace Decho.ViewModels;
public sealed class ChannelViewModel(ChannelModel model) : ViewModelBase
{
public ChannelModel Model { get; } = model;
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; } = new ObservableCollection<MessageViewModel>(
model.Messages.Select(message => new MessageViewModel(message)));
public void ClearMessages()
{
Model.Messages.Clear();
Messages.Clear();
}
public void AddMessage(MessageModel message)
{
Model.Messages.Add(message);
Messages.Add(new MessageViewModel(message));
}
}
+83
View File
@@ -0,0 +1,83 @@
using EchoHub.Client.Commands;
using System.Collections.ObjectModel;
namespace Decho.ViewModels;
public sealed class ChatViewModel : ViewModelBase
{
public ObservableCollection<MessageViewModel> Messages
{
get;
private set => this.RaiseAndSetIfChanged(ref field, value);
} = [];
public string ChannelTitle
{
get;
private set => this.RaiseAndSetIfChanged(ref field, value);
} = "Select a channel";
public string? ChannelTopic
{
get;
set
{
_ = this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(HasTopic));
}
}
public bool HasTopic
{
get;
private set => this.RaiseAndSetIfChanged(ref field, value);
}
public MessageComposerViewModel Composer { get; }
public string CurrentServerUrl { get; private set; } = string.Empty;
public string CurrentChannelName { get; private set; } = string.Empty;
public ChatViewModel()
{
Composer = new MessageComposerViewModel();
}
public void SetChannel(ChannelViewModel? channel, string serverUrl = "")
{
if (channel is null)
{
Messages = [];
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 ClearMessages()
{
Messages = [];
ChannelTitle = "Select a channel";
ChannelTopic = null;
HasTopic = false;
}
}
+918
View File
@@ -0,0 +1,918 @@
using Avalonia.Controls;
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 System.Reactive;
using System.Reactive.Linq;
namespace Decho.ViewModels;
public sealed class MainWindowViewModel : ViewModelBase
{
private readonly CommandHandler _commandHandler;
private Window? _mainWindow;
public string Title { get; } = "Decho";
public SidebarViewModel Sidebar { get; }
public ChatViewModel Chat { get; }
public string StatusText
{
get;
set => this.RaiseAndSetIfChanged(ref field, value);
} = "Ready";
public ConnectionService ConnectionService { get; }
public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
public MainWindowViewModel()
{
ConnectionService = new ConnectionService();
_commandHandler = new CommandHandler();
Sidebar = new SidebarViewModel();
Chat = new ChatViewModel();
AddServerCommand = ReactiveCommand.Create(AddServer);
Chat.Composer.SendRequested += HandleSendRequested;
Chat.Composer.CommandRequested += HandleCommandAsync;
Chat.Composer.FileUploadRequested += HandleFileUploadRequested;
WireCommandHandlerEvents();
WireConnectionServiceEvents();
_ = Sidebar.WhenAnyValue(x => x.SelectedChannel)
.Subscribe(HandleChannelSelected);
_ = InitializeSavedServersAsync();
}
public void SetMainWindow(Window window)
{
_mainWindow = window;
}
public void Dispose()
{
ConnectionService.Dispose();
}
private void AddServer()
{
ServerModel placeholderServer = new ServerModel(
Guid.NewGuid().ToString("N"),
"New Server",
[],
isConnected: false);
ServerViewModel 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) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.UpdateStatusAsync(serverUrl, status, message);
};
_commandHandler.OnSetTheme += themeName =>
{
return Task.CompletedTask;
};
_commandHandler.OnJoinChannel += async channelName =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
List<MessageModel> channel = await ConnectionService.JoinChannelAsync(serverUrl, channelName);
EnsureChannelInList(serverUrl, channelName);
ChannelModel? channelModel = FindChannel(serverUrl, channelName);
if (channelModel is not null)
{
ChannelViewModel? channelVm = Sidebar.GetServer(serverUrl)?.Channels
.FirstOrDefault(c => c.Name == channelName);
if (channelVm is not null)
{
foreach (MessageModel msg in channel)
{
channelVm.AddMessage(msg);
}
}
}
};
_commandHandler.OnLeaveChannel += async () =>
{
string serverUrl = GetCurrentServerUrl();
string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
if (channel == HubConstants.DefaultChannel)
{
return;
}
await ConnectionService.LeaveChannelAsync(serverUrl, channel);
};
_commandHandler.OnListUsers += async () =>
{
string serverUrl = GetCurrentServerUrl();
string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel);
string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username));
StatusText = $"Online in #{channel}: {userList}";
};
_commandHandler.OnSetTopic += async topic =>
{
string serverUrl = GetCurrentServerUrl();
string 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) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.KickUserAsync(serverUrl, username, reason);
};
_commandHandler.OnBanUser += async (username, reason) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.BanUserAsync(serverUrl, username, reason);
};
_commandHandler.OnUnbanUser += async username =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.UnbanUserAsync(serverUrl, username);
};
_commandHandler.OnMuteUser += async (username, duration) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.MuteUserAsync(serverUrl, username, duration);
};
_commandHandler.OnUnmuteUser += async username =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.UnmuteUserAsync(serverUrl, username);
};
_commandHandler.OnAssignRole += async (username, roleStr) =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
ServerRole role = roleStr.ToLowerInvariant() switch
{
"admin" => ServerRole.Admin,
"mod" => ServerRole.Mod,
_ => ServerRole.Member,
};
await ConnectionService.AssignRoleAsync(serverUrl, username, role);
};
_commandHandler.OnNukeChannel += async () =>
{
string serverUrl = GetCurrentServerUrl();
string 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) =>
{
string serverUrl = GetCurrentServerUrl();
string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
try
{
if (Uri.TryCreate(target, UriKind.Absolute, out Uri? 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 =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.UpdateProfileAsync(serverUrl, displayName, null, null);
};
_commandHandler.OnSetColor += async color =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.UpdateProfileAsync(serverUrl, null, null, color);
};
_commandHandler.OnSetAvatar += async target =>
{
string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl))
{
return;
}
await ConnectionService.SetAvatarAsync(serverUrl, target);
};
_commandHandler.OnOpenProfile += async username =>
{
StatusText = $"Profile: {username ?? "self"}";
};
_commandHandler.OnOpenServers += () =>
{
ClientConfig config = ConfigManager.Load();
string 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 =>
{
ServerViewModel 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(() =>
{
ServerViewModel? existing = Sidebar.GetServer(server.ServerUrl);
if (existing is not null)
{
_ = Sidebar.Servers.Remove(existing);
}
Sidebar.Servers.Add(serverVm);
StatusText = $"Connected to {server.Name}";
});
};
ConnectionService.ServerRemoved += serverUrl =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Sidebar.RemoveServer(serverUrl);
if (Sidebar.Servers.Count == 0)
{
Chat.ClearMessages();
StatusText = "Ready";
}
});
};
ConnectionService.ServerStateChanged += server =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ServerViewModel? 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(() =>
{
ChannelViewModel? channelVm = FindChannelViewModel(serverUrl, message.ChannelName);
channelVm?.AddMessage(message);
});
};
ConnectionService.ErrorOccurred += (serverUrl, error) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
StatusText = $"Error: {error}";
});
};
}
private async Task<string?> HandleCommandAsync(string commandText)
{
CommandResult result = await _commandHandler.HandleAsync(commandText);
if (result.Message is not null && !result.IsError)
{
Chat.Messages.Add(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;
}
if (_commandHandler.IsCommand(text))
{
_ = HandleCommandAsync(text);
return;
}
_ = 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 void HandleFileUploadRequested(string serverUrl, string filePath)
{
if (string.IsNullOrEmpty(Chat.CurrentChannelName))
{
return;
}
_ = Task.Run(async () =>
{
try
{
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;
}
string serverUrl = FindServerUrlForChannel(channel);
Chat.SetChannel(channel, serverUrl);
if (!string.IsNullOrEmpty(serverUrl))
{
Chat.SetComposerCommandHandler(new CommandHandler());
_ = Task.Run(async () =>
{
try
{
List<MessageModel> history = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name);
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
channel.ClearMessages();
foreach (MessageModel msg in history)
{
channel.AddMessage(msg);
}
});
}
catch
{
// Channel might not be available
}
});
}
}
private async Task InitializeSavedServersAsync()
{
ClientConfig config = ConfigManager.Load();
List<Task> connectTasks = [];
foreach (SavedServer saved in config.SavedServers)
{
ServerModel placeholderServer = new ServerModel(
Guid.NewGuid().ToString("N"),
saved.Name,
[],
saved.Url,
isConnected: false);
ServerViewModel 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);
});
if (!string.IsNullOrEmpty(saved.RefreshToken) && saved.RememberMe)
{
connectTasks.Add(AutoConnectSavedServer(serverVm, saved));
}
}
await Task.WhenAll(connectTasks);
}
private async Task AutoConnectSavedServer(ServerViewModel serverVm, SavedServer saved)
{
if (string.IsNullOrEmpty(saved.Username) || string.IsNullOrEmpty(saved.RefreshToken))
{
return;
}
try
{
serverVm.IsConnecting = true;
await ConnectionService.ConnectWithSavedTokenAsync(
saved.Url, saved.Username, saved.RefreshToken, saved.RememberMe);
}
catch
{
serverVm.IsConnecting = false;
}
}
private async Task HandleServerConnectRequested(ServerViewModel serverVm)
{
// Show connect dialog
if (_mainWindow is null)
{
return;
}
ClientConfig config = ConfigManager.Load();
SavedServer? prefill = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, serverVm.ServerUrl, StringComparison.OrdinalIgnoreCase));
ConnectDialog dialog = new ConnectDialog(config.SavedServers, prefill);
ConnectDialogResult? 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
string? refreshToken = ConnectionService.GetRefreshToken(result.ServerUrl);
SavedServer 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,
};
ConfigManager.SaveServer(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)
{
ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
if (serverVm is null)
{
return;
}
if (!serverVm.Channels.Any(c => c.Name == channelName))
{
ChannelModel channelModel = new ChannelModel(
Guid.NewGuid().ToString("N"),
channelName,
[]);
ChannelViewModel channelVm = new ChannelViewModel(channelModel);
serverVm.Channels.Add(channelVm);
}
}
private ChannelModel? FindChannel(string serverUrl, string channelName)
{
ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName)?.Model;
}
private ChannelViewModel? FindChannelViewModel(string serverUrl, string channelName)
{
ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName);
}
private string FindServerUrlForChannel(ChannelViewModel channel)
{
foreach (ServerViewModel server in Sidebar.Servers)
{
if (server.Channels.Contains(channel))
{
return server.ServerUrl;
}
}
return string.Empty;
}
private string GetCurrentServerUrl()
{
return Chat.CurrentServerUrl;
}
}
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(List<SavedServer> savedServers, SavedServer? prefill = null)
{
private readonly List<SavedServer> _savedServers = savedServers;
private readonly SavedServer? _prefill = prefill;
public async Task<ConnectDialogResult?> ShowAsync(Window owner)
{
Window dialog = new Window
{
Title = "Connect to Server",
Width = 450,
Height = 380,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
};
StackPanel stack = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(15) };
TextBlock urlLabel = new TextBlock { Text = "Server URL:" };
TextBox urlBox = new TextBox { Watermark = "http://localhost:5000", Text = "http://localhost:5000" };
stack.Children.Add(urlLabel);
stack.Children.Add(urlBox);
TextBlock userLabel = new TextBlock { Text = "Username:" };
TextBox userBox = new TextBox { Watermark = "username" };
stack.Children.Add(userLabel);
stack.Children.Add(userBox);
TextBlock passLabel = new TextBlock { Text = "Password:" };
TextBox passBox = new TextBox { Watermark = "password", PasswordChar = '*' };
stack.Children.Add(passLabel);
stack.Children.Add(passBox);
CheckBox rememberMe = new CheckBox { Content = "Remember me", IsChecked = true };
stack.Children.Add(rememberMe);
StackPanel buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
Button loginBtn = new Button { Content = "Login", Width = 100 };
Button registerBtn = new Button { Content = "Register", Width = 100 };
Button 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)
{
TextBlock sep = new TextBlock { Text = "--- Saved Servers ---", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
stack.Children.Add(sep);
serversList = new ListBox
{
Height = 100,
ItemsSource = _savedServers.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"}").ToList()
};
stack.Children.Add(serversList);
serversList.SelectionChanged += (_, _) =>
{
int idx = serversList.SelectedIndex;
if (idx >= 0 && idx < _savedServers.Count)
{
SavedServer 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 ?? "";
int 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 };
TaskCompletionSource<ConnectDialogResult?> tcs = new TaskCompletionSource<ConnectDialogResult?>();
ConnectDialogResult? result = null;
loginBtn.Click += async (_, _) =>
{
string url = urlBox.Text?.Trim() ?? "";
string user = userBox.Text?.Trim() ?? "";
string 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
SavedServer? 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 += (_, _) =>
{
string url = urlBox.Text?.Trim() ?? "";
string user = userBox.Text?.Trim() ?? "";
string 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)
{
Window msgBox = new Window
{
Title = title,
Width = 350,
Height = 150,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
};
StackPanel stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) };
stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap });
Button okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
stack.Children.Add(okBtn);
msgBox.Content = stack;
TaskCompletionSource tcs = new TaskCompletionSource();
okBtn.Click += (_, _) => { msgBox.Close(); _ = tcs.TrySetResult(); };
msgBox.Closed += (_, _) => tcs.TrySetResult();
await msgBox.ShowDialog(owner);
await tcs.Task;
}
}
@@ -0,0 +1,78 @@
using EchoHub.Client.Commands;
using System.Reactive;
namespace Decho.ViewModels;
public sealed class MessageComposerViewModel : ViewModelBase
{
public event Action<string, string>? SendRequested;
public event Func<string, Task<string?>>? CommandRequested;
public event Action<string, string>? FileUploadRequested;
private CommandHandler? _commandHandler;
public string Draft
{
get;
set => this.RaiseAndSetIfChanged(ref field, value);
} = string.Empty;
public string ServerUrl { get; private set; } = string.Empty;
public ReactiveCommand<Unit, Unit> SendCommand { get; }
public bool HasCommandHandler => _commandHandler is not null;
public MessageComposerViewModel()
{
IObservable<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
SendCommand = ReactiveCommand.Create(Send, canSend);
}
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)
{
return _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
}
private void Send()
{
string text = Draft.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return;
}
Draft = string.Empty;
if (_commandHandler is not null && _commandHandler.IsCommand(text))
{
_ = (CommandRequested?.Invoke(text));
}
else
{
SendRequested?.Invoke(ServerUrl, text);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using Avalonia.Media;
using Decho.Models;
using EchoHub.Core.Models;
namespace Decho.ViewModels;
public sealed class MessageViewModel(MessageModel model) : ViewModelBase
{
public MessageModel Model { get; } = model;
public string AuthorName => Model.Author.DisplayName;
public IBrush? AuthorColor
{
get
{
string? 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
{
DateTimeOffset now = DateTimeOffset.Now;
if (Model.SentAt.Date == now.Date)
{
return Model.SentAt.ToLocalTime().ToString("t");
}
return Model.SentAt.ToLocalTime().ToString("g");
}
}
}
+152
View File
@@ -0,0 +1,152 @@
using Decho.Models;
using System.Collections.ObjectModel;
using System.Reactive;
namespace Decho.ViewModels;
public sealed class ServerViewModel : ViewModelBase
{
public event Func<Task>? ConnectRequested
{
add => _connectRequested = (Func<Task>?)Delegate.Combine(_connectRequested, value);
remove => _connectRequested = (Func<Task>?)Delegate.Remove(_connectRequested, value);
}
public event Func<Task>? DisconnectRequested
{
add => _disconnectRequested = (Func<Task>?)Delegate.Combine(_disconnectRequested, value);
remove => _disconnectRequested = (Func<Task>?)Delegate.Remove(_disconnectRequested, value);
}
private bool _isConnected;
private bool _isConnecting;
private string? _connectedUser;
private Func<Task>? _connectRequested;
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;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public bool IsExpanded
{
get;
set => this.RaiseAndSetIfChanged(ref field, value);
} = true;
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
{
get
{
if (IsConnected)
{
return IsConnecting ? "Connecting..." : $"Connected as {ConnectedUser}";
}
else
{
return IsConnecting ? "Connecting..." : "Disconnected";
}
}
}
public string ConnectionStatusColor
{
get
{
if (IsConnecting)
{
return IsConnected ? "Green" : "Orange";
}
else
{
return IsConnected ? "Green" : "Gray";
}
}
}
public bool ShowConnectionControls => !IsConnected && !IsConnecting;
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 void SyncFromModel()
{
IsConnected = Model.IsConnected;
IsConnecting = Model.IsConnecting;
ConnectedUser = Model.ConnectedUser;
IsExpanded = true;
}
private async Task ConnectAsync()
{
if (_connectRequested is not null)
{
await _connectRequested();
}
}
private async Task DisconnectAsync()
{
if (_disconnectRequested is not null)
{
await _disconnectRequested();
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Collections.ObjectModel;
using System.Reactive.Linq;
namespace Decho.ViewModels;
public sealed class SidebarViewModel : ViewModelBase
{
public ObservableCollection<ServerViewModel> Servers { get; }
public ChannelViewModel? SelectedChannel
{
get;
set
{
if (ReferenceEquals(field, value))
{
return;
}
_ = this.RaiseAndSetIfChanged(ref field, value);
if (value is null)
{
return;
}
foreach (ServerViewModel server in Servers)
{
if (server.Channels.Contains(value))
{
if (!ReferenceEquals(server.SelectedChannel, value))
{
server.SelectedChannel = value;
}
}
else if (server.SelectedChannel is not null)
{
server.SelectedChannel = null;
}
}
}
}
public SidebarViewModel()
{
Servers = [];
}
public void RemoveServer(string serverUrl)
{
ServerViewModel? 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));
}
}
+5
View File
@@ -0,0 +1,5 @@
namespace Decho.ViewModels;
public abstract class ViewModelBase : ReactiveObject
{
}
+24
View File
@@ -0,0 +1,24 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:views="using:Decho.Views"
xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.ChannelListView"
x:DataType="vm:ServerViewModel">
<ListBox Background="Transparent"
BorderThickness="0"
Margin="0"
ItemsSource="{Binding Channels}"
SelectedItem="{Binding SelectedChannel, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ChannelViewModel">
<TextBlock>
<InlineUIContainer BaselineAlignment="Bottom">
<i:Icon Value="fa-hashtag" FontWeight="Bold" Margin="0 0 0 1" />
</InlineUIContainer>
<Run Text="{Binding Name}" />
</TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Decho.Views;
public partial class ChannelListView : UserControl
{
public ChannelListView()
{
InitializeComponent();
}
}
+16
View File
@@ -0,0 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:views="using:Decho.Views"
x:Class="Decho.Views.ChatView"
x:DataType="vm:ChatViewModel">
<Grid RowDefinitions="Auto, *, Auto" Margin="10">
<TextBlock FontWeight="Bold"
FontSize="16"
Margin="0 0 0 8"
Text="{Binding ChannelTitle}" />
<views:MessageListView Grid.Row="1" />
<views:MessageComposerView Grid.Row="2"
DataContext="{Binding Composer}" />
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Decho.Views;
public partial class ChatView : UserControl
{
public ChatView()
{
InitializeComponent();
}
}
+48
View File
@@ -0,0 +1,48 @@
<Window xmlns="https://github.com/avaloniaui"
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">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto, *" ColumnDefinitions="Auto, *">
<DockPanel HorizontalAlignment="Stretch">
<TextBlock FontSize="20"
FontWeight="Bold"
Margin="5"
Text="{Binding Title}" />
<Button i:Attached.Icon="fa-cog"
DockPanel.Dock="Right"
Background="Transparent"
Foreground="{DynamicResource UiTheme08}"
HorizontalAlignment="Right" />
<Button i:Attached.Icon="fa-plus"
Command="{Binding AddServerCommand}"
DockPanel.Dock="Right"
Background="Transparent"
Foreground="{DynamicResource UiTheme08}"
HorizontalAlignment="Right"
ToolTip.Tip="Add Server" />
</DockPanel>
<views:SidebarView Grid.Row="1"
Grid.Column="0"
DataContext="{Binding Sidebar}"
SelectedChannel="{Binding SelectedChannel, Mode=TwoWay}" />
<views:ChatView Grid.RowSpan="2"
Grid.Column="1"
DataContext="{Binding Chat}" />
</Grid>
</Window>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Decho.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
+29
View File
@@ -0,0 +1,29 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel">
<Grid ColumnDefinitions="*, Auto, Auto" Margin="5">
<TextBox Grid.Column="0"
HorizontalAlignment="Stretch"
Margin="0 0 5 0"
Text="{Binding Draft, Mode=TwoWay}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1"
Height="33"
Width="33"
Margin="0 0 5 0"
Content="Send"
i:Attached.Icon="fa-paper-plane"
Command="{Binding SendCommand}" />
<Button Grid.Column="2"
Height="33"
Width="33"
Click="OnFileUploadClicked"
i:Attached.Icon="fa-paperclip" />
</Grid>
</UserControl>
@@ -0,0 +1,74 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Decho.ViewModels;
namespace Decho.Views;
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;
}
TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is null)
{
return;
}
IReadOnlyList<IStorageFile> files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
Title = "Select a file to upload",
});
IStorageFile? 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
string? 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);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
x:Class="Decho.Views.MessageItemView"
x:DataType="vm:MessageViewModel">
<Grid Margin="5" ColumnDefinitions="Auto, *">
<Ellipse VerticalAlignment="Top"
Width="40"
Height="40"
Fill="{DynamicResource UiTheme00}"
Margin="0 0 8 0" />
<StackPanel Grid.Column="1" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal" Margin="0 5 0 0">
<TextBlock VerticalAlignment="Top"
Text="{Binding AuthorName}"
FontWeight="Bold"
Foreground="{Binding AuthorColor}" />
<TextBlock VerticalAlignment="Top"
Text="{Binding TimeText}"
FontSize="10"
Margin="5 0 0 0" />
</StackPanel>
<TextBlock VerticalAlignment="Top"
Text="{Binding Content}"
TextWrapping="Wrap"
IsVisible="{Binding ShowContent}" />
<Image x:Name="MessageImage"
MaxHeight="300"
MaxWidth="400"
HorizontalAlignment="Left"
IsVisible="{Binding IsImage}"
Margin="0 4 0 0" />
<StackPanel IsVisible="{Binding HasAttachment}" Margin="0 4 0 0" Spacing="2">
<TextBlock Text="{Binding AttachmentFileName}"
FontSize="11"
Opacity="0.7" />
<Button Content="Download"
Click="OnDownloadClicked"
FontSize="11"
Padding="5 1"
HorizontalAlignment="Left" />
</StackPanel>
</StackPanel>
</Grid>
</UserControl>
+121
View File
@@ -0,0 +1,121 @@
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;
MessageViewModel msg = (MessageViewModel)DataContext!;
_ = LoadImageAsync(msg, _loadCts!.Token);
}
private async Task LoadImageAsync(MessageViewModel msg, CancellationToken ct)
{
try
{
TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.DataContext is not MainWindowViewModel mainVm)
{
return;
}
byte[]? bytes = await mainVm.ConnectionService.DownloadImageBytesAsync(
msg.ServerUrl ?? "", msg.AttachmentUrl!);
ct.ThrowIfCancellationRequested();
if (bytes is null || bytes.Length == 0)
{
return;
}
using MemoryStream stream = new MemoryStream(bytes);
Bitmap bitmap = new Bitmap(stream);
ct.ThrowIfCancellationRequested();
Image? image = this.FindControl<Image>("MessageImage");
_ = 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;
}
TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is null)
{
return;
}
if (topLevel.DataContext is not MainWindowViewModel mainVm)
{
return;
}
string? tempPath = await mainVm.ConnectionService.DownloadAttachmentAsync(msg.ServerUrl ?? "", msg.AttachmentUrl, msg.AttachmentFileName ?? "download");
if (tempPath is null)
{
return;
}
IStorageFile? 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
{
// Ignore if temp file cannot be deleted
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:views="using:Decho.Views"
x:Class="Decho.Views.MessageListView"
x:DataType="vm:ChatViewModel">
<ScrollViewer Name="MessageScrollViewer">
<ItemsControl ItemsSource="{Binding Messages}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MessageViewModel">
<views:MessageItemView />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>
+102
View File
@@ -0,0 +1,102 @@
using Avalonia.Controls;
using Avalonia.Threading;
using Decho.ViewModels;
using System.Collections.Specialized;
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;
}
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
scroll?.ScrollChanged -= OnScrollChanged;
_chat = DataContext as ChatViewModel;
if (_chat is not null)
{
_chat.Messages.CollectionChanged += OnMessagesChanged;
_chat.PropertyChanged += OnViewModelPropertyChanged;
scroll?.ScrollChanged += OnScrollChanged;
}
}
private void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (e.ExtentDelta.Y > 0 && _wasAtBottom)
{
ScrollViewer scroll = (ScrollViewer)sender!;
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
}
ScrollViewer 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()
{
ScrollViewer? scroll = this.FindControl<ScrollViewer>("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()
{
ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
if (scroll is null)
{
return;
}
_wasAtBottom = true;
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
}
}
+55
View File
@@ -0,0 +1,55 @@
<UserControl xmlns="https://github.com/avaloniaui"
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">
<Border Background="{DynamicResource UiTheme00}" Width="250" CornerRadius="0 6 0 0">
<ScrollViewer Padding="5">
<ItemsControl ItemsSource="{Binding Servers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ServerViewModel">
<Expander Padding="0"
Margin="0 0 0 6"
HorizontalAlignment="Stretch"
IsExpanded="{Binding IsExpanded}">
<Expander.Header>
<StackPanel Spacing="5">
<StackPanel Orientation="Horizontal" Spacing="5">
<Ellipse Width="8" Height="8"
Fill="{Binding ConnectionStatusColor}"
VerticalAlignment="Center" />
<TextBlock FontWeight="Bold"
TextTrimming="CharacterEllipsis"
MaxWidth="160"
Padding="0"
Text="{Binding Name}" />
</StackPanel>
<TextBlock FontSize="10"
Text="{Binding ConnectionStatusText}"
Opacity="0.6"
VerticalAlignment="Center" />
</StackPanel>
</Expander.Header>
<StackPanel>
<Button Content="Connect"
Command="{Binding ConnectCommand}"
IsVisible="{Binding ShowConnectionControls}"
Margin="0 0 0 4"
Padding="5 2"
HorizontalAlignment="Stretch" />
<Button Content="Disconnect"
Command="{Binding DisconnectCommand}"
IsVisible="{Binding IsConnected}"
Margin="0 0 0 4"
Padding="5 2"
HorizontalAlignment="Stretch" />
<views:ChannelListView />
</StackPanel>
</Expander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</UserControl>
+25
View File
@@ -0,0 +1,25 @@
using Avalonia;
using Avalonia.Controls;
using Decho.ViewModels;
namespace Decho.Views;
public partial class SidebarView : UserControl
{
public static readonly StyledProperty<ChannelViewModel?> SelectedChannelProperty =
AvaloniaProperty.Register<SidebarView, ChannelViewModel?>(
nameof(SelectedChannel),
defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public ChannelViewModel? SelectedChannel
{
get => GetValue(SelectedChannelProperty);
set => SetValue(SelectedChannelProperty, value);
}
public SidebarView()
{
InitializeComponent();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Decho.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
Submodule
+1
Submodule src/EchoHub added at 3538ec8005