Code cleanup

This commit is contained in:
Stone_Red
2026-07-15 20:42:43 +02:00
parent ebac2b4eb7
commit b08358baed
21 changed files with 1065 additions and 980 deletions
+7 -21
View File
@@ -2,29 +2,15 @@ using System.Collections.ObjectModel;
namespace Decho.Models; namespace Decho.Models;
public sealed class ChannelModel public sealed class ChannelModel(string id, string name, ObservableCollection<MessageModel> messages, string? topic = null, bool isPublic = true)
{ {
public ChannelModel( public string Id { get; } = id;
string id,
string name,
ObservableCollection<MessageModel> 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; } = name;
public string Name { get; } public string? Topic { get; set; } = topic;
public string? Topic { get; set; } public bool IsPublic { get; } = isPublic;
public bool IsPublic { get; } public ObservableCollection<MessageModel> Messages { get; } = messages;
}
public ObservableCollection<MessageModel> Messages { get; }
}
+12 -36
View File
@@ -2,49 +2,25 @@ using EchoHub.Core.Models;
namespace Decho.Models; namespace Decho.Models;
public sealed class MessageModel 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 MessageModel( public string Id { get; } = id;
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; } public UserModel Author { get; } = author;
public UserModel Author { get; } public DateTimeOffset SentAt { get; } = sentAt;
public DateTimeOffset SentAt { get; } public string Content { get; } = content;
public string Content { get; } public string ChannelName { get; } = channelName;
public string ChannelName { get; } public string? ServerUrl { get; } = serverUrl;
public string? ServerUrl { get; } public MessageType Type { get; } = type;
public MessageType Type { get; } public string? AttachmentUrl { get; } = attachmentUrl;
public string? AttachmentUrl { get; } public string? AttachmentFileName { get; } = attachmentFileName;
public string? AttachmentFileName { get; } public long? AttachmentFileSize { get; } = attachmentFileSize;
}
public long? AttachmentFileSize { get; }
}
+9 -27
View File
@@ -2,37 +2,19 @@ using System.Collections.ObjectModel;
namespace Decho.Models; namespace Decho.Models;
public sealed class ServerModel public sealed class ServerModel(string id, string name, ObservableCollection<ChannelModel> channels, string serverUrl = "", bool isConnected = false, bool isConnecting = false, string? connectedUser = null)
{ {
public ServerModel( public string Id { get; } = id;
string id,
string name,
ObservableCollection<ChannelModel> 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; } = name;
public string Name { get; } public string ServerUrl { get; set; } = serverUrl;
public string ServerUrl { get; set; } public bool IsConnected { get; set; } = isConnected;
public bool IsConnected { get; set; } public bool IsConnecting { get; set; } = isConnecting;
public bool IsConnecting { get; set; } public string? ConnectedUser { get; set; } = connectedUser;
public string? ConnectedUser { get; set; } public ObservableCollection<ChannelModel> Channels { get; } = channels;
}
public ObservableCollection<ChannelModel> Channels { get; }
}
+7 -21
View File
@@ -2,29 +2,15 @@ using EchoHub.Core.Models;
namespace Decho.Models; namespace Decho.Models;
public sealed class UserModel public sealed class UserModel(string id, string displayName, string? nicknameColor = null, UserStatus status = UserStatus.Online, string? statusMessage = null)
{ {
public UserModel( public string Id { get; } = id;
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; } = displayName;
public string DisplayName { get; } public string? NicknameColor { get; set; } = nicknameColor;
public string? NicknameColor { get; set; } public UserStatus Status { get; set; } = status;
public UserStatus Status { get; set; } public string? StatusMessage { get; set; } = statusMessage;
}
public string? StatusMessage { get; set; }
}
+7 -6
View File
@@ -6,23 +6,24 @@ using Avalonia;
using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.FontAwesome;
using System;
namespace Decho; namespace Decho;
internal sealed class Program internal static class Program
{ {
// Initialization code. Don't use any Avalonia, third-party APIs or any // Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break. // yet and stuff might break.
[STAThread] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp() public static void Main(string[] args)
{
_ = BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args); .StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer. // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
{ {
IconProvider.Current _ = IconProvider.Current
.Register<FontAwesomeIconProvider>(); .Register<FontAwesomeIconProvider>();
return AppBuilder.Configure<App>() return AppBuilder.Configure<App>()
@@ -31,4 +32,4 @@ internal sealed class Program
.LogToTrace() .LogToTrace()
.UseReactiveUI(); .UseReactiveUI();
} }
} }
+423 -338
View File
@@ -1,38 +1,45 @@
using System.Collections.ObjectModel; using Decho.Models;
using EchoHub.Client.Commands;
using EchoHub.Client.Config; using EchoHub.Client.Config;
using EchoHub.Client.Services; using EchoHub.Client.Services;
using EchoHub.Client.Commands; using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using Decho.Models;
using System.Collections.ObjectModel;
namespace Decho.Services; namespace Decho.Services;
public sealed class ConnectionService : IDisposable public sealed class ConnectionService : IDisposable
{ {
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
public event Action<ServerModel>? ServerAdded; public event Action<ServerModel>? ServerAdded;
public event Action<string>? ServerRemoved; public event Action<string>? ServerRemoved;
public event Action<ServerModel>? ServerStateChanged; public event Action<ServerModel>? ServerStateChanged;
public event Action<string, ChannelModel>? ChannelAdded; public event Action<string, ChannelModel>? ChannelAdded;
public event Action<string, string>? ChannelRemoved; public event Action<string, string>? ChannelRemoved;
public event Action<string, MessageModel>? MessageReceived; public event Action<string, MessageModel>? MessageReceived;
public event Action<string, string, string?>? UserJoined; public event Action<string, string, string?>? UserJoined;
public event Action<string, string>? UserLeft; public event Action<string, string>? UserLeft;
public event Action<string, string>? ErrorOccurred; public event Action<string, string>? ErrorOccurred;
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections; internal IReadOnlyDictionary<string, ServerConnection> Connections => _connections;
internal ServerConnection? GetConnection(string serverUrl) =>
_connections.TryGetValue(serverUrl, out var conn) ? conn : null;
public async Task<ServerModel> ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe) public async Task<ServerModel> ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe)
{ {
var conn = new ConnectionManager(); ConnectionManager conn = new ConnectionManager();
var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult( ConnectDialogResult dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult(
serverUrl, username, password, isRegister, rememberMe, null); serverUrl, username, password, isRegister, rememberMe, null);
ConnectResult result; ConnectResult result;
@@ -46,12 +53,12 @@ public sealed class ConnectionService : IDisposable
throw; throw;
} }
var login = result.Login; LoginResponse login = result.Login;
var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, UserModel userModel = new UserModel(login.Username, login.DisplayName ?? login.Username,
login.NicknameColor); login.NicknameColor);
var channels = new ObservableCollection<ChannelModel>(); ObservableCollection<ChannelModel> channels = [];
var serverModel = new ServerModel( ServerModel serverModel = new ServerModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
new Uri(serverUrl).Host, new Uri(serverUrl).Host,
channels, channels,
@@ -59,11 +66,11 @@ public sealed class ConnectionService : IDisposable
isConnected: true, isConnected: true,
connectedUser: login.Username); connectedUser: login.Username);
var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel); ServerConnection serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
foreach (var ch in result.Channels) foreach (ChannelDto ch in result.Channels)
{ {
var channelModel = ChannelModelFromDto(ch); ChannelModel channelModel = ChannelModelFromDto(ch);
channels.Add(channelModel); channels.Add(channelModel);
} }
@@ -78,9 +85,9 @@ public sealed class ConnectionService : IDisposable
public async Task ConnectWithSavedTokenAsync(string serverUrl, string username, string refreshToken, bool rememberMe) public async Task ConnectWithSavedTokenAsync(string serverUrl, string username, string refreshToken, bool rememberMe)
{ {
var conn = new ConnectionManager(); ConnectionManager conn = new ConnectionManager();
var dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult( ConnectDialogResult dialogResult = new EchoHub.Client.UI.Dialogs.ConnectDialogResult(
serverUrl, username, "", false, rememberMe, refreshToken); serverUrl, username, "", false, rememberMe, refreshToken);
ConnectResult result; ConnectResult result;
@@ -94,11 +101,11 @@ public sealed class ConnectionService : IDisposable
throw; throw;
} }
var login = result.Login; LoginResponse login = result.Login;
var userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, login.NicknameColor); UserModel userModel = new UserModel(login.Username, login.DisplayName ?? login.Username, login.NicknameColor);
var channels = new ObservableCollection<ChannelModel>(); ObservableCollection<ChannelModel> channels = [];
var serverModel = new ServerModel( ServerModel serverModel = new ServerModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
new Uri(serverUrl).Host, new Uri(serverUrl).Host,
channels, channels,
@@ -106,11 +113,11 @@ public sealed class ConnectionService : IDisposable
isConnected: true, isConnected: true,
connectedUser: login.Username); connectedUser: login.Username);
var serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel); ServerConnection serverEntry = new ServerConnection(conn, conn.Api!, serverModel, userModel);
foreach (var ch in result.Channels) foreach (ChannelDto ch in result.Channels)
{ {
var channelModel = ChannelModelFromDto(ch); ChannelModel channelModel = ChannelModelFromDto(ch);
channels.Add(channelModel); channels.Add(channelModel);
} }
@@ -121,15 +128,395 @@ public sealed class ConnectionService : IDisposable
ServerAdded?.Invoke(serverModel); 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) private void SaveRefreshToken(string serverUrl, bool rememberMe)
{ {
if (!rememberMe) return; if (!rememberMe)
if (!_connections.TryGetValue(serverUrl, out var entry)) return; {
var token = entry.ApiClient.RefreshToken; return;
if (string.IsNullOrEmpty(token)) return; }
var config = ConfigManager.Load(); if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
var saved = config.SavedServers.FirstOrDefault(s => {
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)); string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
if (saved is null) if (saved is null)
{ {
@@ -149,280 +536,11 @@ public sealed class ConnectionService : IDisposable
ConfigManager.Save(config); 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<List<MessageModel>> 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<List<MessageModel>> 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<List<UserPresenceDto>> 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<ChannelDto?> 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<string?> 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<byte[]?> DownloadImageBytesAsync(string serverUrl, string relativeUrl)
{
if (!_connections.TryGetValue(serverUrl, out var entry))
return null;
try
{
var tempPath = await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, "image");
if (tempPath is null) return null;
var bytes = await File.ReadAllBytesAsync(tempPath);
try { File.Delete(tempPath); } catch { }
return bytes;
}
catch
{
return null;
}
}
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) private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn)
{ {
conn.MessageReceived += message => conn.MessageReceived += message =>
{ {
var msg = MessageModelFromDto(message, entry); MessageModel msg = MessageModelFromDto(message, entry);
MessageReceived?.Invoke(entry.Server.ServerUrl, msg); MessageReceived?.Invoke(entry.Server.ServerUrl, msg);
}; };
@@ -444,12 +562,9 @@ public sealed class ConnectionService : IDisposable
conn.ChannelUpdated += channel => conn.ChannelUpdated += channel =>
{ {
var existing = entry.Server.Channels.FirstOrDefault(c => ChannelModel? existing = entry.Server.Channels.FirstOrDefault(c =>
string.Equals(c.Name, channel.Name, StringComparison.OrdinalIgnoreCase)); string.Equals(c.Name, channel.Name, StringComparison.OrdinalIgnoreCase));
if (existing is not null) _ = existing?.Topic = channel.Topic;
{
existing.Topic = channel.Topic;
}
}; };
conn.ForceDisconnected += reason => conn.ForceDisconnected += reason =>
@@ -471,44 +586,14 @@ public sealed class ConnectionService : IDisposable
ServerStateChanged?.Invoke(entry.Server); ServerStateChanged?.Invoke(entry.Server);
}; };
} }
internal static ChannelModel ChannelModelFromDto(ChannelDto dto)
{
return new ChannelModel(
dto.Id.ToString(),
dto.Name,
new ObservableCollection<MessageModel>(),
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 sealed class ServerConnection
{ {
internal ConnectionManager Manager { get; }
public ApiClient ApiClient { get; } public ApiClient ApiClient { get; }
public ServerModel Server { get; } public ServerModel Server { get; }
public UserModel User { get; } public UserModel User { get; }
internal ConnectionManager Manager { get; }
internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user) internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user)
{ {
+6 -4
View File
@@ -3,10 +3,10 @@ using Avalonia.Controls.Templates;
using Decho.ViewModels; using Decho.ViewModels;
using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
namespace Decho; namespace Decho;
/// <summary> /// <summary>
/// Given a view model, returns the corresponding view if possible. /// Given a view model, returns the corresponding view if possible.
/// </summary> /// </summary>
@@ -18,10 +18,12 @@ public class ViewLocator : IDataTemplate
public Control? Build(object? param) public Control? Build(object? param)
{ {
if (param is null) if (param is null)
{
return null; return null;
}
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); string name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name); Type? type = Type.GetType(name);
if (type != null) if (type != null)
{ {
@@ -35,4 +37,4 @@ public class ViewLocator : IDataTemplate
{ {
return data is ViewModelBase; return data is ViewModelBase;
} }
} }
+6 -18
View File
@@ -1,20 +1,12 @@
using System.Collections.ObjectModel;
using System.Linq;
using Decho.Models; using Decho.Models;
using System.Collections.ObjectModel;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class ChannelViewModel : ViewModelBase public sealed class ChannelViewModel(ChannelModel model) : ViewModelBase
{ {
public ChannelViewModel(ChannelModel model) public ChannelModel Model { get; } = model;
{
Model = model;
Messages = new ObservableCollection<MessageViewModel>(
model.Messages.Select(message => new MessageViewModel(message)));
}
public ChannelModel Model { get; }
public string Name => Model.Name; public string Name => Model.Name;
@@ -36,7 +28,8 @@ public sealed class ChannelViewModel : ViewModelBase
public bool IsPublic => Model.IsPublic; public bool IsPublic => Model.IsPublic;
public ObservableCollection<MessageViewModel> Messages { get; } public ObservableCollection<MessageViewModel> Messages { get; } = new ObservableCollection<MessageViewModel>(
model.Messages.Select(message => new MessageViewModel(message)));
public void ClearMessages() public void ClearMessages()
{ {
@@ -49,9 +42,4 @@ public sealed class ChannelViewModel : ViewModelBase
Model.Messages.Add(message); Model.Messages.Add(message);
Messages.Add(new MessageViewModel(message)); Messages.Add(new MessageViewModel(message));
} }
public void AddMessageViewModel(MessageViewModel messageViewModel)
{
Messages.Add(messageViewModel);
}
} }
+25 -51
View File
@@ -1,72 +1,60 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using EchoHub.Client.Commands; using EchoHub.Client.Commands;
using ReactiveUI;
using System.Collections.ObjectModel;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class ChatViewModel : ViewModelBase public sealed class ChatViewModel : ViewModelBase
{ {
private ObservableCollection<MessageViewModel> _messages = new();
private string _channelTitle = "Select a channel";
private string? _channelTopic;
private bool _hasTopic;
private string _currentServerUrl = string.Empty;
private string _currentChannelName = string.Empty;
public ChatViewModel()
{
Composer = new MessageComposerViewModel();
Composer.CommandRequested += HandleCommandAsync;
}
public ObservableCollection<MessageViewModel> Messages public ObservableCollection<MessageViewModel> Messages
{ {
get => _messages; get;
private set => this.RaiseAndSetIfChanged(ref _messages, value); private set => this.RaiseAndSetIfChanged(ref field, value);
} } = [];
public string ChannelTitle public string ChannelTitle
{ {
get => _channelTitle; get;
private set => this.RaiseAndSetIfChanged(ref _channelTitle, value); private set => this.RaiseAndSetIfChanged(ref field, value);
} } = "Select a channel";
public string? ChannelTopic public string? ChannelTopic
{ {
get => _channelTopic; get;
set set
{ {
this.RaiseAndSetIfChanged(ref _channelTopic, value); _ = this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(HasTopic)); this.RaisePropertyChanged(nameof(HasTopic));
} }
} }
public bool HasTopic public bool HasTopic
{ {
get => _hasTopic; get;
private set => this.RaiseAndSetIfChanged(ref _hasTopic, value); private set => this.RaiseAndSetIfChanged(ref field, value);
} }
public MessageComposerViewModel Composer { get; } public MessageComposerViewModel Composer { get; }
public string CurrentServerUrl => _currentServerUrl; public string CurrentServerUrl { get; private set; } = string.Empty;
public string CurrentChannelName => _currentChannelName;
public event Func<string, Task<string?>>? CommandRequested; public string CurrentChannelName { get; private set; } = string.Empty;
public ChatViewModel()
{
Composer = new MessageComposerViewModel();
}
public void SetChannel(ChannelViewModel? channel, string serverUrl = "") public void SetChannel(ChannelViewModel? channel, string serverUrl = "")
{ {
if (channel is null) if (channel is null)
{ {
Messages = new ObservableCollection<MessageViewModel>(); Messages = [];
ChannelTitle = "Select a channel"; ChannelTitle = "Select a channel";
ChannelTopic = null; ChannelTopic = null;
HasTopic = false; HasTopic = false;
_currentChannelName = string.Empty; CurrentChannelName = string.Empty;
_currentServerUrl = string.Empty; CurrentServerUrl = string.Empty;
Composer.SetServer(string.Empty); Composer.SetServer(string.Empty);
return; return;
} }
@@ -75,8 +63,8 @@ public sealed class ChatViewModel : ViewModelBase
ChannelTitle = "#" + channel.Name; ChannelTitle = "#" + channel.Name;
ChannelTopic = channel.Topic; ChannelTopic = channel.Topic;
HasTopic = channel.HasTopic; HasTopic = channel.HasTopic;
_currentChannelName = channel.Name; CurrentChannelName = channel.Name;
_currentServerUrl = serverUrl; CurrentServerUrl = serverUrl;
Composer.SetServer(serverUrl); Composer.SetServer(serverUrl);
} }
@@ -85,25 +73,11 @@ public sealed class ChatViewModel : ViewModelBase
Composer.SetCommandHandler(handler); Composer.SetCommandHandler(handler);
} }
public void AddMessage(MessageViewModel message)
{
Messages.Add(message);
}
public void ClearMessages() public void ClearMessages()
{ {
Messages = new ObservableCollection<MessageViewModel>(); Messages = [];
ChannelTitle = "Select a channel"; ChannelTitle = "Select a channel";
ChannelTopic = null; ChannelTopic = null;
HasTopic = false; HasTopic = false;
} }
private async Task<string?> HandleCommandAsync(string commandText)
{
if (CommandRequested is not null)
{
return await CommandRequested(commandText);
}
return null;
}
} }
+279 -239
View File
@@ -1,14 +1,4 @@
using System;
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;
using Avalonia.Controls.ApplicationLifetimes;
using Decho.Models; using Decho.Models;
using Decho.Services; using Decho.Services;
@@ -19,16 +9,14 @@ using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using ReactiveUI; using System.Reactive;
using System.Reactive.Linq;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class MainWindowViewModel : ViewModelBase public sealed class MainWindowViewModel : ViewModelBase
{ {
private readonly ConnectionService _connectionService;
private readonly CommandHandler _commandHandler; private readonly CommandHandler _commandHandler;
private string _statusText = "Ready";
private bool _isConnected;
private Window? _mainWindow; private Window? _mainWindow;
public string Title { get; } = "Decho"; public string Title { get; } = "Decho";
@@ -39,24 +27,18 @@ public sealed class MainWindowViewModel : ViewModelBase
public string StatusText public string StatusText
{ {
get => _statusText; get;
set => this.RaiseAndSetIfChanged(ref _statusText, value); set => this.RaiseAndSetIfChanged(ref field, value);
} } = "Ready";
public bool IsConnected public ConnectionService ConnectionService { get; }
{
get => _isConnected;
set => this.RaiseAndSetIfChanged(ref _isConnected, value);
}
public ConnectionService ConnectionService => _connectionService;
public ReactiveCommand<Unit, Unit> AddServerCommand { get; } public ReactiveCommand<Unit, Unit> AddServerCommand { get; }
public MainWindowViewModel() public MainWindowViewModel()
{ {
_connectionService = new ConnectionService(); ConnectionService = new ConnectionService();
_commandHandler = _connectionService.CreateCommandHandler(); _commandHandler = new CommandHandler();
Sidebar = new SidebarViewModel(); Sidebar = new SidebarViewModel();
Chat = new ChatViewModel(); Chat = new ChatViewModel();
@@ -70,8 +52,8 @@ public sealed class MainWindowViewModel : ViewModelBase
WireCommandHandlerEvents(); WireCommandHandlerEvents();
WireConnectionServiceEvents(); WireConnectionServiceEvents();
Sidebar.WhenAnyValue(x => x.SelectedChannel) _ = Sidebar.WhenAnyValue(x => x.SelectedChannel)
.Subscribe(channel => HandleChannelSelected(channel)); .Subscribe(HandleChannelSelected);
_ = InitializeSavedServersAsync(); _ = InitializeSavedServersAsync();
} }
@@ -81,15 +63,20 @@ public sealed class MainWindowViewModel : ViewModelBase
_mainWindow = window; _mainWindow = window;
} }
public void Dispose()
{
ConnectionService.Dispose();
}
private void AddServer() private void AddServer()
{ {
var placeholderServer = new ServerModel( ServerModel placeholderServer = new ServerModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
"New Server", "New Server",
new ObservableCollection<ChannelModel>(), [],
isConnected: false); isConnected: false);
var serverVm = new ServerViewModel(placeholderServer); ServerViewModel serverVm = new ServerViewModel(placeholderServer);
serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm); serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm);
serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm); serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm);
@@ -101,9 +88,13 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
_commandHandler.OnSetStatus += async (status, message) => _commandHandler.OnSetStatus += async (status, message) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.UpdateStatusAsync(serverUrl, status, message); {
return;
}
await ConnectionService.UpdateStatusAsync(serverUrl, status, message);
}; };
_commandHandler.OnSetTheme += themeName => _commandHandler.OnSetTheme += themeName =>
@@ -113,111 +104,156 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnJoinChannel += async channelName => _commandHandler.OnJoinChannel += async channelName =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
{
return;
}
var channel = await _connectionService.JoinChannelAsync(serverUrl, channelName); List<MessageModel> channel = await ConnectionService.JoinChannelAsync(serverUrl, channelName);
EnsureChannelInList(serverUrl, channelName); EnsureChannelInList(serverUrl, channelName);
var channelModel = FindChannel(serverUrl, channelName); ChannelModel? channelModel = FindChannel(serverUrl, channelName);
if (channelModel is not null) if (channelModel is not null)
{ {
var channelVm = Sidebar.GetServer(serverUrl)?.Channels ChannelViewModel? channelVm = Sidebar.GetServer(serverUrl)?.Channels
.FirstOrDefault(c => c.Name == channelName); .FirstOrDefault(c => c.Name == channelName);
if (channelVm is not null) if (channelVm is not null)
{ {
foreach (var msg in channel) foreach (MessageModel msg in channel)
{
channelVm.AddMessage(msg); channelVm.AddMessage(msg);
}
} }
} }
}; };
_commandHandler.OnLeaveChannel += async () => _commandHandler.OnLeaveChannel += async () =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
var channel = Chat.CurrentChannelName; string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
if (channel == HubConstants.DefaultChannel) return; if (channel == HubConstants.DefaultChannel)
await _connectionService.LeaveChannelAsync(serverUrl, channel); {
return;
}
await ConnectionService.LeaveChannelAsync(serverUrl, channel);
}; };
_commandHandler.OnListUsers += async () => _commandHandler.OnListUsers += async () =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
var channel = Chat.CurrentChannelName; string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
var users = await _connectionService.GetOnlineUsersAsync(serverUrl, channel); List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel);
var userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username)); string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username));
StatusText = $"Online in #{channel}: {userList}"; StatusText = $"Online in #{channel}: {userList}";
}; };
_commandHandler.OnSetTopic += async topic => _commandHandler.OnSetTopic += async topic =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
var channel = Chat.CurrentChannelName; string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
await _connectionService.UpdateProfileAsync(serverUrl, null, null, null); await ConnectionService.UpdateProfileAsync(serverUrl, null, null, null);
_connectionService.UpdateChannelTopic(serverUrl, channel, topic); ConnectionService.UpdateChannelTopic(serverUrl, channel, topic);
Chat.ChannelTopic = topic; Chat.ChannelTopic = topic;
}; };
_commandHandler.OnKickUser += async (username, reason) => _commandHandler.OnKickUser += async (username, reason) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.KickUserAsync(serverUrl, username, reason); {
return;
}
await ConnectionService.KickUserAsync(serverUrl, username, reason);
}; };
_commandHandler.OnBanUser += async (username, reason) => _commandHandler.OnBanUser += async (username, reason) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.BanUserAsync(serverUrl, username, reason); {
return;
}
await ConnectionService.BanUserAsync(serverUrl, username, reason);
}; };
_commandHandler.OnUnbanUser += async username => _commandHandler.OnUnbanUser += async username =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.UnbanUserAsync(serverUrl, username); {
return;
}
await ConnectionService.UnbanUserAsync(serverUrl, username);
}; };
_commandHandler.OnMuteUser += async (username, duration) => _commandHandler.OnMuteUser += async (username, duration) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.MuteUserAsync(serverUrl, username, duration); {
return;
}
await ConnectionService.MuteUserAsync(serverUrl, username, duration);
}; };
_commandHandler.OnUnmuteUser += async username => _commandHandler.OnUnmuteUser += async username =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.UnmuteUserAsync(serverUrl, username); {
return;
}
await ConnectionService.UnmuteUserAsync(serverUrl, username);
}; };
_commandHandler.OnAssignRole += async (username, roleStr) => _commandHandler.OnAssignRole += async (username, roleStr) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
{
return;
}
var role = roleStr.ToLowerInvariant() switch ServerRole role = roleStr.ToLowerInvariant() switch
{ {
"admin" => ServerRole.Admin, "admin" => ServerRole.Admin,
"mod" => ServerRole.Mod, "mod" => ServerRole.Mod,
_ => ServerRole.Member, _ => ServerRole.Member,
}; };
await _connectionService.AssignRoleAsync(serverUrl, username, role); await ConnectionService.AssignRoleAsync(serverUrl, username, role);
}; };
_commandHandler.OnNukeChannel += async () => _commandHandler.OnNukeChannel += async () =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
var channel = Chat.CurrentChannelName; string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
await _connectionService.NukeChannelAsync(serverUrl, channel); {
return;
}
await ConnectionService.NukeChannelAsync(serverUrl, channel);
}; };
_commandHandler.OnTestSound += () => Task.CompletedTask; _commandHandler.OnTestSound += () => Task.CompletedTask;
@@ -225,7 +261,10 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnQuit += () => _commandHandler.OnQuit += () =>
{ {
if (_mainWindow is not null) if (_mainWindow is not null)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => _mainWindow.Close()); Avalonia.Threading.Dispatcher.UIThread.Post(() => _mainWindow.Close());
}
return Task.CompletedTask; return Task.CompletedTask;
}; };
@@ -233,20 +272,23 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnSendFile += async (target, size) => _commandHandler.OnSendFile += async (target, size) =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
var channel = Chat.CurrentChannelName; string channel = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(channel))
{
return;
}
try try
{ {
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (uri.Scheme == "http" || uri.Scheme == "https"))
{ {
await _connectionService.SendUrlAsync(serverUrl, channel, target, size); await ConnectionService.SendUrlAsync(serverUrl, channel, target, size);
} }
else else
{ {
await _connectionService.UploadFileAsync(serverUrl, channel, target, size); await ConnectionService.UploadFileAsync(serverUrl, channel, target, size);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -257,23 +299,35 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnSetNick += async displayName => _commandHandler.OnSetNick += async displayName =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.UpdateProfileAsync(serverUrl, displayName, null, null); {
return;
}
await ConnectionService.UpdateProfileAsync(serverUrl, displayName, null, null);
}; };
_commandHandler.OnSetColor += async color => _commandHandler.OnSetColor += async color =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.UpdateProfileAsync(serverUrl, null, null, color); {
return;
}
await ConnectionService.UpdateProfileAsync(serverUrl, null, null, color);
}; };
_commandHandler.OnSetAvatar += async target => _commandHandler.OnSetAvatar += async target =>
{ {
var serverUrl = GetCurrentServerUrl(); string serverUrl = GetCurrentServerUrl();
if (string.IsNullOrEmpty(serverUrl)) return; if (string.IsNullOrEmpty(serverUrl))
await _connectionService.SetAvatarAsync(serverUrl, target); {
return;
}
await ConnectionService.SetAvatarAsync(serverUrl, target);
}; };
_commandHandler.OnOpenProfile += async username => _commandHandler.OnOpenProfile += async username =>
@@ -283,8 +337,8 @@ public sealed class MainWindowViewModel : ViewModelBase
_commandHandler.OnOpenServers += () => _commandHandler.OnOpenServers += () =>
{ {
var config = _connectionService.LoadConfig(); ClientConfig config = ConfigManager.Load();
var servers = string.Join("\n", config.SavedServers.Select(s => string servers = string.Join("\n", config.SavedServers.Select(s =>
$"{s.Name} ({s.Url}) - {s.Username ?? "?"}")); $"{s.Name} ({s.Url}) - {s.Username ?? "?"}"));
StatusText = servers; StatusText = servers;
return Task.CompletedTask; return Task.CompletedTask;
@@ -293,75 +347,67 @@ public sealed class MainWindowViewModel : ViewModelBase
private void WireConnectionServiceEvents() private void WireConnectionServiceEvents()
{ {
_connectionService.ServerAdded += server => ConnectionService.ServerAdded += server =>
{ {
var serverVm = new ServerViewModel(server); ServerViewModel serverVm = new ServerViewModel(server);
serverVm.ConnectRequested += () => HandleServerConnectRequested(serverVm); serverVm.ConnectRequested += () => HandleServerConnectRequested(serverVm);
serverVm.DisconnectRequested += () => HandleServerDisconnectRequested(serverVm); serverVm.DisconnectRequested += () => HandleServerDisconnectRequested(serverVm);
serverVm.WhenAnyValue(s => s.SelectedChannel) _ = serverVm.WhenAnyValue(s => s.SelectedChannel)
.Where(channel => channel is not null) .Where(channel => channel is not null)
.Subscribe(channel => Sidebar.SelectedChannel = channel!); .Subscribe(channel => Sidebar.SelectedChannel = channel!);
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
var existing = Sidebar.GetServer(server.ServerUrl); ServerViewModel? existing = Sidebar.GetServer(server.ServerUrl);
if (existing is not null) if (existing is not null)
Sidebar.Servers.Remove(existing); {
_ = Sidebar.Servers.Remove(existing);
}
Sidebar.Servers.Add(serverVm); Sidebar.Servers.Add(serverVm);
StatusText = $"Connected to {server.Name}"; StatusText = $"Connected to {server.Name}";
IsConnected = true;
}); });
}; };
_connectionService.ServerRemoved += serverUrl => ConnectionService.ServerRemoved += serverUrl =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
Sidebar.RemoveServer(serverUrl); Sidebar.RemoveServer(serverUrl);
if (Sidebar.Servers.Count == 0) if (Sidebar.Servers.Count == 0)
{ {
IsConnected = false;
Chat.ClearMessages(); Chat.ClearMessages();
StatusText = "Ready"; StatusText = "Ready";
} }
}); });
}; };
_connectionService.ServerStateChanged += server => ConnectionService.ServerStateChanged += server =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
var serverVm = Sidebar.GetServer(server.ServerUrl); ServerViewModel? serverVm = Sidebar.GetServer(server.ServerUrl);
if (serverVm is not null) if (serverVm is not null)
{ {
serverVm.SyncFromModel(); serverVm.SyncFromModel();
if (!server.IsConnected) if (!server.IsConnected)
{
StatusText = $"Disconnected from {server.Name}"; StatusText = $"Disconnected from {server.Name}";
}
} }
}); });
}; };
_connectionService.MessageReceived += (serverUrl, message) => ConnectionService.MessageReceived += (serverUrl, message) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
var channelVm = FindChannelViewModel(serverUrl, message.ChannelName); ChannelViewModel? channelVm = FindChannelViewModel(serverUrl, message.ChannelName);
if (channelVm is not null) channelVm?.AddMessage(message);
{
channelVm.AddMessage(message);
}
}); });
}; };
_connectionService.ChannelAdded += (serverUrl, channel) => ConnectionService.ErrorOccurred += (serverUrl, error) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
EnsureChannelInList(serverUrl, channel.Name);
});
};
_connectionService.ErrorOccurred += (serverUrl, error) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
@@ -372,10 +418,10 @@ public sealed class MainWindowViewModel : ViewModelBase
private async Task<string?> HandleCommandAsync(string commandText) private async Task<string?> HandleCommandAsync(string commandText)
{ {
var result = await _commandHandler.HandleAsync(commandText); CommandResult result = await _commandHandler.HandleAsync(commandText);
if (result.Message is not null && !result.IsError) if (result.Message is not null && !result.IsError)
{ {
Chat.AddMessage(new MessageViewModel(new MessageModel( Chat.Messages.Add(new MessageViewModel(new MessageModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
new UserModel("system", "System"), new UserModel("system", "System"),
DateTimeOffset.Now, DateTimeOffset.Now,
@@ -389,19 +435,21 @@ public sealed class MainWindowViewModel : ViewModelBase
private void HandleSendRequested(string serverUrl, string text) private void HandleSendRequested(string serverUrl, string text)
{ {
if (string.IsNullOrEmpty(Chat.CurrentChannelName)) if (string.IsNullOrEmpty(Chat.CurrentChannelName))
{
return; return;
}
if (_commandHandler.IsCommand(text)) if (_commandHandler.IsCommand(text))
{ {
_ = HandleCommandAsync(text); _ = HandleCommandAsync(text);
return; return;
} }
Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
await _connectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text); await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -415,13 +463,16 @@ public sealed class MainWindowViewModel : ViewModelBase
private void HandleFileUploadRequested(string serverUrl, string filePath) private void HandleFileUploadRequested(string serverUrl, string filePath)
{ {
if (string.IsNullOrEmpty(Chat.CurrentChannelName)) return; if (string.IsNullOrEmpty(Chat.CurrentChannelName))
{
return;
}
Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
await _connectionService.UploadFileAsync(serverUrl, Chat.CurrentChannelName, filePath, null); await ConnectionService.UploadFileAsync(serverUrl, Chat.CurrentChannelName, filePath, null);
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
StatusText = "File uploaded"); StatusText = "File uploaded");
} }
@@ -441,24 +492,25 @@ public sealed class MainWindowViewModel : ViewModelBase
return; return;
} }
var serverUrl = FindServerUrlForChannel(channel); string serverUrl = FindServerUrlForChannel(channel);
Chat.SetChannel(channel, serverUrl); Chat.SetChannel(channel, serverUrl);
if (!string.IsNullOrEmpty(serverUrl)) if (!string.IsNullOrEmpty(serverUrl))
{ {
var commandHandler = _connectionService.CreateCommandHandler(); Chat.SetComposerCommandHandler(new CommandHandler());
Chat.SetComposerCommandHandler(commandHandler);
Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
{ {
var history = await _connectionService.JoinChannelAsync(serverUrl, channel.Name); List<MessageModel> history = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name);
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
channel.ClearMessages(); channel.ClearMessages();
foreach (var msg in history) foreach (MessageModel msg in history)
{
channel.AddMessage(msg); channel.AddMessage(msg);
}
}); });
} }
catch catch
@@ -471,19 +523,19 @@ public sealed class MainWindowViewModel : ViewModelBase
private async Task InitializeSavedServersAsync() private async Task InitializeSavedServersAsync()
{ {
var config = _connectionService.LoadConfig(); ClientConfig config = ConfigManager.Load();
var connectTasks = new List<Task>(); List<Task> connectTasks = [];
foreach (var saved in config.SavedServers) foreach (SavedServer saved in config.SavedServers)
{ {
var placeholderServer = new ServerModel( ServerModel placeholderServer = new ServerModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
saved.Name, saved.Name,
new ObservableCollection<ChannelModel>(), [],
saved.Url, saved.Url,
isConnected: false); isConnected: false);
var serverVm = new ServerViewModel(placeholderServer); ServerViewModel serverVm = new ServerViewModel(placeholderServer);
serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm); serverVm.ConnectRequested += async () => await HandleServerConnectRequested(serverVm);
serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm); serverVm.DisconnectRequested += async () => await HandleServerDisconnectRequested(serverVm);
@@ -504,12 +556,14 @@ public sealed class MainWindowViewModel : ViewModelBase
private async Task AutoConnectSavedServer(ServerViewModel serverVm, SavedServer saved) private async Task AutoConnectSavedServer(ServerViewModel serverVm, SavedServer saved)
{ {
if (string.IsNullOrEmpty(saved.Username) || string.IsNullOrEmpty(saved.RefreshToken)) if (string.IsNullOrEmpty(saved.Username) || string.IsNullOrEmpty(saved.RefreshToken))
{
return; return;
}
try try
{ {
serverVm.IsConnecting = true; serverVm.IsConnecting = true;
await _connectionService.ConnectWithSavedTokenAsync( await ConnectionService.ConnectWithSavedTokenAsync(
saved.Url, saved.Username, saved.RefreshToken, saved.RememberMe); saved.Url, saved.Username, saved.RefreshToken, saved.RememberMe);
} }
catch catch
@@ -518,43 +572,24 @@ public sealed class MainWindowViewModel : ViewModelBase
} }
} }
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) private async Task HandleServerConnectRequested(ServerViewModel serverVm)
{ {
// Show connect dialog // Show connect dialog
if (_mainWindow is null) return; if (_mainWindow is null)
{
return;
}
var config = _connectionService.LoadConfig(); ClientConfig config = ConfigManager.Load();
var prefill = config.SavedServers.FirstOrDefault(s => SavedServer? prefill = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, serverVm.ServerUrl, StringComparison.OrdinalIgnoreCase)); string.Equals(s.Url, serverVm.ServerUrl, StringComparison.OrdinalIgnoreCase));
var dialog = new ConnectDialog(config.SavedServers, prefill); ConnectDialog dialog = new ConnectDialog(config.SavedServers, prefill);
var result = await dialog.ShowAsync(_mainWindow); ConnectDialogResult? result = await dialog.ShowAsync(_mainWindow);
if (result is null) return; if (result is null)
{
return;
}
try try
{ {
@@ -563,12 +598,12 @@ public sealed class MainWindowViewModel : ViewModelBase
if (result.IsSavedSession && result.SavedRefreshToken is not null) if (result.IsSavedSession && result.SavedRefreshToken is not null)
{ {
await _connectionService.ConnectWithSavedTokenAsync( await ConnectionService.ConnectWithSavedTokenAsync(
result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe); result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe);
} }
else else
{ {
await _connectionService.ConnectAsync( _ = await ConnectionService.ConnectAsync(
result.ServerUrl, result.Username, result.Password, result.IsRegister, result.RememberMe); result.ServerUrl, result.Username, result.Password, result.IsRegister, result.RememberMe);
} }
@@ -576,8 +611,8 @@ public sealed class MainWindowViewModel : ViewModelBase
Sidebar.RemoveServer(serverVm.ServerUrl); Sidebar.RemoveServer(serverVm.ServerUrl);
// Save to config with refresh token // Save to config with refresh token
var refreshToken = _connectionService.GetRefreshToken(result.ServerUrl); string? refreshToken = ConnectionService.GetRefreshToken(result.ServerUrl);
var savedServer = new SavedServer SavedServer savedServer = new SavedServer
{ {
Name = new Uri(result.ServerUrl).Host, Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl, Url = result.ServerUrl,
@@ -586,7 +621,7 @@ public sealed class MainWindowViewModel : ViewModelBase
RememberMe = result.RememberMe, RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now, LastConnected = DateTimeOffset.Now,
}; };
_connectionService.SaveServerToConfig(savedServer); ConfigManager.SaveServer(savedServer);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -599,7 +634,7 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
try try
{ {
await _connectionService.DisconnectAsync(serverVm.ServerUrl); await ConnectionService.DisconnectAsync(serverVm.ServerUrl);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -609,38 +644,43 @@ public sealed class MainWindowViewModel : ViewModelBase
private void EnsureChannelInList(string serverUrl, string channelName) private void EnsureChannelInList(string serverUrl, string channelName)
{ {
var serverVm = Sidebar.GetServer(serverUrl); ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
if (serverVm is null) return; if (serverVm is null)
{
return;
}
if (!serverVm.Channels.Any(c => c.Name == channelName)) if (!serverVm.Channels.Any(c => c.Name == channelName))
{ {
var channelModel = new ChannelModel( ChannelModel channelModel = new ChannelModel(
Guid.NewGuid().ToString("N"), Guid.NewGuid().ToString("N"),
channelName, channelName,
new System.Collections.ObjectModel.ObservableCollection<MessageModel>()); []);
var channelVm = new ChannelViewModel(channelModel); ChannelViewModel channelVm = new ChannelViewModel(channelModel);
serverVm.Channels.Add(channelVm); serverVm.Channels.Add(channelVm);
} }
} }
private ChannelModel? FindChannel(string serverUrl, string channelName) private ChannelModel? FindChannel(string serverUrl, string channelName)
{ {
var serverVm = Sidebar.GetServer(serverUrl); ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName)?.Model; return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName)?.Model;
} }
private ChannelViewModel? FindChannelViewModel(string serverUrl, string channelName) private ChannelViewModel? FindChannelViewModel(string serverUrl, string channelName)
{ {
var serverVm = Sidebar.GetServer(serverUrl); ServerViewModel? serverVm = Sidebar.GetServer(serverUrl);
return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName); return serverVm?.Channels.FirstOrDefault(c => c.Name == channelName);
} }
private string FindServerUrlForChannel(ChannelViewModel channel) private string FindServerUrlForChannel(ChannelViewModel channel)
{ {
foreach (var server in Sidebar.Servers) foreach (ServerViewModel server in Sidebar.Servers)
{ {
if (server.Channels.Contains(channel)) if (server.Channels.Contains(channel))
{
return server.ServerUrl; return server.ServerUrl;
}
} }
return string.Empty; return string.Empty;
} }
@@ -649,11 +689,6 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
return Chat.CurrentServerUrl; return Chat.CurrentServerUrl;
} }
public void Dispose()
{
_connectionService.Dispose();
}
} }
public sealed class ConnectDialogResult public sealed class ConnectDialogResult
@@ -667,20 +702,14 @@ public sealed class ConnectDialogResult
public string? SavedRefreshToken { get; set; } public string? SavedRefreshToken { get; set; }
} }
public sealed class ConnectDialog public sealed class ConnectDialog(List<SavedServer> savedServers, SavedServer? prefill = null)
{ {
private readonly List<SavedServer> _savedServers; private readonly List<SavedServer> _savedServers = savedServers;
private readonly SavedServer? _prefill; private readonly SavedServer? _prefill = prefill;
public ConnectDialog(List<SavedServer> savedServers, SavedServer? prefill = null)
{
_savedServers = savedServers;
_prefill = prefill;
}
public async Task<ConnectDialogResult?> ShowAsync(Window owner) public async Task<ConnectDialogResult?> ShowAsync(Window owner)
{ {
var dialog = new Window Window dialog = new Window
{ {
Title = "Connect to Server", Title = "Connect to Server",
Width = 450, Width = 450,
@@ -689,31 +718,31 @@ public sealed class ConnectDialog
CanResize = false, CanResize = false,
}; };
var stack = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(15) }; StackPanel stack = new StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(15) };
var urlLabel = new TextBlock { Text = "Server URL:" }; TextBlock urlLabel = new TextBlock { Text = "Server URL:" };
var urlBox = new TextBox { Watermark = "http://localhost:5000", Text = "http://localhost:5000" }; TextBox urlBox = new TextBox { Watermark = "http://localhost:5000", Text = "http://localhost:5000" };
stack.Children.Add(urlLabel); stack.Children.Add(urlLabel);
stack.Children.Add(urlBox); stack.Children.Add(urlBox);
var userLabel = new TextBlock { Text = "Username:" }; TextBlock userLabel = new TextBlock { Text = "Username:" };
var userBox = new TextBox { Watermark = "username" }; TextBox userBox = new TextBox { Watermark = "username" };
stack.Children.Add(userLabel); stack.Children.Add(userLabel);
stack.Children.Add(userBox); stack.Children.Add(userBox);
var passLabel = new TextBlock { Text = "Password:" }; TextBlock passLabel = new TextBlock { Text = "Password:" };
var passBox = new TextBox { Watermark = "password", PasswordChar = '*' }; TextBox passBox = new TextBox { Watermark = "password", PasswordChar = '*' };
stack.Children.Add(passLabel); stack.Children.Add(passLabel);
stack.Children.Add(passBox); stack.Children.Add(passBox);
var rememberMe = new CheckBox { Content = "Remember me", IsChecked = true }; CheckBox rememberMe = new CheckBox { Content = "Remember me", IsChecked = true };
stack.Children.Add(rememberMe); stack.Children.Add(rememberMe);
var buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 }; StackPanel buttonPanel = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, Spacing = 10 };
var loginBtn = new Button { Content = "Login", Width = 100 }; Button loginBtn = new Button { Content = "Login", Width = 100 };
var registerBtn = new Button { Content = "Register", Width = 100 }; Button registerBtn = new Button { Content = "Register", Width = 100 };
var cancelBtn = new Button { Content = "Cancel", Width = 100 }; Button cancelBtn = new Button { Content = "Cancel", Width = 100 };
buttonPanel.Children.Add(loginBtn); buttonPanel.Children.Add(loginBtn);
buttonPanel.Children.Add(registerBtn); buttonPanel.Children.Add(registerBtn);
buttonPanel.Children.Add(cancelBtn); buttonPanel.Children.Add(cancelBtn);
@@ -722,24 +751,29 @@ public sealed class ConnectDialog
ListBox? serversList = null; ListBox? serversList = null;
if (_savedServers.Count > 0) if (_savedServers.Count > 0)
{ {
var sep = new TextBlock { Text = "--- Saved Servers ---", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; TextBlock sep = new TextBlock { Text = "--- Saved Servers ---", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
stack.Children.Add(sep); stack.Children.Add(sep);
serversList = new ListBox { Height = 100 }; serversList = new ListBox
serversList.ItemsSource = _savedServers.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"}").ToList(); {
Height = 100,
ItemsSource = _savedServers.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"}").ToList()
};
stack.Children.Add(serversList); stack.Children.Add(serversList);
serversList.SelectionChanged += (_, _) => serversList.SelectionChanged += (_, _) =>
{ {
var idx = serversList.SelectedIndex; int idx = serversList.SelectedIndex;
if (idx >= 0 && idx < _savedServers.Count) if (idx >= 0 && idx < _savedServers.Count)
{ {
var s = _savedServers[idx]; SavedServer s = _savedServers[idx];
urlBox.Text = s.Url; urlBox.Text = s.Url;
userBox.Text = s.Username ?? ""; userBox.Text = s.Username ?? "";
rememberMe.IsChecked = s.RememberMe; rememberMe.IsChecked = s.RememberMe;
if (!string.IsNullOrEmpty(s.RefreshToken)) if (!string.IsNullOrEmpty(s.RefreshToken))
{
passBox.Text = ""; passBox.Text = "";
}
} }
}; };
} }
@@ -748,22 +782,24 @@ public sealed class ConnectDialog
{ {
urlBox.Text = _prefill.Url; urlBox.Text = _prefill.Url;
userBox.Text = _prefill.Username ?? ""; userBox.Text = _prefill.Username ?? "";
var idx = _savedServers.FindIndex(s => int idx = _savedServers.FindIndex(s =>
string.Equals(s.Url, _prefill.Url, StringComparison.OrdinalIgnoreCase)); string.Equals(s.Url, _prefill.Url, StringComparison.OrdinalIgnoreCase));
if (idx >= 0 && idx < _savedServers.Count && serversList is not null) if (idx >= 0 && idx < _savedServers.Count && serversList is not null)
{
serversList.SelectedIndex = idx; serversList.SelectedIndex = idx;
}
} }
dialog.Content = new ScrollViewer { Content = stack }; dialog.Content = new ScrollViewer { Content = stack };
var tcs = new TaskCompletionSource<ConnectDialogResult?>(); TaskCompletionSource<ConnectDialogResult?> tcs = new TaskCompletionSource<ConnectDialogResult?>();
ConnectDialogResult? result = null; ConnectDialogResult? result = null;
loginBtn.Click += async (_, _) => loginBtn.Click += async (_, _) =>
{ {
var url = urlBox.Text?.Trim() ?? ""; string url = urlBox.Text?.Trim() ?? "";
var user = userBox.Text?.Trim() ?? ""; string user = userBox.Text?.Trim() ?? "";
var pass = passBox.Text ?? ""; string pass = passBox.Text ?? "";
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{ {
@@ -772,7 +808,7 @@ public sealed class ConnectDialog
} }
// Check for saved session // Check for saved session
var saved = _savedServers.FirstOrDefault(s => SavedServer? saved = _savedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase) && string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase) &&
string.Equals(s.Username, user, StringComparison.OrdinalIgnoreCase) && string.Equals(s.Username, user, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(s.RefreshToken)); !string.IsNullOrEmpty(s.RefreshToken));
@@ -807,14 +843,14 @@ public sealed class ConnectDialog
} }
dialog.Close(); dialog.Close();
tcs.TrySetResult(result); _ = tcs.TrySetResult(result);
}; };
registerBtn.Click += (_, _) => registerBtn.Click += (_, _) =>
{ {
var url = urlBox.Text?.Trim() ?? ""; string url = urlBox.Text?.Trim() ?? "";
var user = userBox.Text?.Trim() ?? ""; string user = userBox.Text?.Trim() ?? "";
var pass = passBox.Text ?? ""; string pass = passBox.Text ?? "";
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{ {
@@ -832,28 +868,32 @@ public sealed class ConnectDialog
}; };
dialog.Close(); dialog.Close();
tcs.TrySetResult(result); _ = tcs.TrySetResult(result);
}; };
cancelBtn.Click += (_, _) => cancelBtn.Click += (_, _) =>
{ {
dialog.Close(); dialog.Close();
tcs.TrySetResult(null); _ = tcs.TrySetResult(null);
}; };
dialog.Closed += (_, _) => tcs.TrySetResult(result); dialog.Closed += (_, _) => tcs.TrySetResult(result);
if (owner is not null) if (owner is not null)
{
await dialog.ShowDialog(owner); await dialog.ShowDialog(owner);
}
else else
{
dialog.Show(); dialog.Show();
}
return await tcs.Task; return await tcs.Task;
} }
private static async Task ShowMessageBox(Window owner, string title, string message) private static async Task ShowMessageBox(Window owner, string title, string message)
{ {
var msgBox = new Window Window msgBox = new Window
{ {
Title = title, Title = title,
Width = 350, Width = 350,
@@ -862,14 +902,14 @@ public sealed class ConnectDialog
CanResize = false, CanResize = false,
}; };
var stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) }; StackPanel stack = new StackPanel { Spacing = 10, Margin = new Avalonia.Thickness(15) };
stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }); stack.Children.Add(new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap });
var okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; Button okBtn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
stack.Children.Add(okBtn); stack.Children.Add(okBtn);
msgBox.Content = stack; msgBox.Content = stack;
var tcs = new TaskCompletionSource(); TaskCompletionSource tcs = new TaskCompletionSource();
okBtn.Click += (_, _) => { msgBox.Close(); tcs.TrySetResult(); }; okBtn.Click += (_, _) => { msgBox.Close(); _ = tcs.TrySetResult(); };
msgBox.Closed += (_, _) => tcs.TrySetResult(); msgBox.Closed += (_, _) => tcs.TrySetResult();
await msgBox.ShowDialog(owner); await msgBox.ShowDialog(owner);
+31 -28
View File
@@ -1,50 +1,48 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using EchoHub.Client.Commands; using EchoHub.Client.Commands;
using ReactiveUI;
using System.Reactive;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class MessageComposerViewModel : ViewModelBase public sealed class MessageComposerViewModel : ViewModelBase
{ {
private string _draft = string.Empty; public event Action<string, string>? SendRequested;
private CommandHandler? _commandHandler;
private string _serverUrl = string.Empty;
public MessageComposerViewModel() public event Func<string, Task<string?>>? CommandRequested;
{
var canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft)); public event Action<string, string>? FileUploadRequested;
SendCommand = ReactiveCommand.Create(Send, canSend);
} private CommandHandler? _commandHandler;
public string Draft public string Draft
{ {
get => _draft; get;
set => this.RaiseAndSetIfChanged(ref _draft, value); set => this.RaiseAndSetIfChanged(ref field, value);
} } = string.Empty;
public string ServerUrl => _serverUrl; public string ServerUrl { get; private set; } = string.Empty;
public ReactiveCommand<Unit, Unit> SendCommand { get; } public ReactiveCommand<Unit, Unit> SendCommand { get; }
public bool HasCommandHandler => _commandHandler is not null; public bool HasCommandHandler => _commandHandler is not null;
public event Action<string, string>? SendRequested; public MessageComposerViewModel()
public event Func<string, Task<string?>>? CommandRequested; {
public event Action<string, string>? FileUploadRequested; IObservable<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
SendCommand = ReactiveCommand.Create(Send, canSend);
}
public void RequestFileUpload(string filePath) public void RequestFileUpload(string filePath)
{ {
if (!string.IsNullOrEmpty(_serverUrl)) if (!string.IsNullOrEmpty(ServerUrl))
FileUploadRequested?.Invoke(_serverUrl, filePath); {
FileUploadRequested?.Invoke(ServerUrl, filePath);
}
} }
public void SetServer(string serverUrl) public void SetServer(string serverUrl)
{ {
_serverUrl = serverUrl; ServerUrl = serverUrl;
} }
public void SetCommandHandler(CommandHandler handler) public void SetCommandHandler(CommandHandler handler)
@@ -53,23 +51,28 @@ public sealed class MessageComposerViewModel : ViewModelBase
this.RaisePropertyChanged(nameof(HasCommandHandler)); this.RaisePropertyChanged(nameof(HasCommandHandler));
} }
public bool IsCommand(string input) => _commandHandler?.IsCommand(input) ?? input.StartsWith('/'); public bool IsCommand(string input)
{
return _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
}
private void Send() private void Send()
{ {
var text = Draft.Trim(); string text = Draft.Trim();
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{
return; return;
}
Draft = string.Empty; Draft = string.Empty;
if (_commandHandler is not null && _commandHandler.IsCommand(text)) if (_commandHandler is not null && _commandHandler.IsCommand(text))
{ {
CommandRequested?.Invoke(text); _ = (CommandRequested?.Invoke(text));
} }
else else
{ {
SendRequested?.Invoke(_serverUrl, text); SendRequested?.Invoke(ServerUrl, text);
} }
} }
} }
+12 -12
View File
@@ -1,18 +1,14 @@
using System;
using Avalonia.Media; using Avalonia.Media;
using EchoHub.Core.Models;
using Decho.Models; using Decho.Models;
using EchoHub.Core.Models;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class MessageViewModel : ViewModelBase public sealed class MessageViewModel(MessageModel model) : ViewModelBase
{ {
public MessageViewModel(MessageModel model) public MessageModel Model { get; } = model;
{
Model = model;
}
public MessageModel Model { get; }
public string AuthorName => Model.Author.DisplayName; public string AuthorName => Model.Author.DisplayName;
@@ -20,8 +16,12 @@ public sealed class MessageViewModel : ViewModelBase
{ {
get get
{ {
var color = Model.Author.NicknameColor; string? color = Model.Author.NicknameColor;
if (string.IsNullOrEmpty(color)) return null; if (string.IsNullOrEmpty(color))
{
return null;
}
try { return new SolidColorBrush(Avalonia.Media.Color.Parse(color)); } try { return new SolidColorBrush(Avalonia.Media.Color.Parse(color)); }
catch { return null; } catch { return null; }
} }
@@ -51,7 +51,7 @@ public sealed class MessageViewModel : ViewModelBase
{ {
get get
{ {
var now = DateTimeOffset.Now; DateTimeOffset now = DateTimeOffset.Now;
if (Model.SentAt.Date == now.Date) if (Model.SentAt.Date == now.Date)
{ {
+120 -92
View File
@@ -1,20 +1,117 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using Decho.Models; using Decho.Models;
using ReactiveUI;
using System.Collections.ObjectModel;
using System.Reactive;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class ServerViewModel : ViewModelBase public sealed class ServerViewModel : ViewModelBase
{ {
private bool _isExpanded = true; public event Func<Task>? ConnectRequested
private ChannelViewModel? _selectedChannel; {
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 _isConnected;
private bool _isConnecting; private bool _isConnecting;
private string? _connectedUser; 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) public ServerViewModel(ServerModel model)
{ {
Model = model; Model = model;
@@ -29,91 +126,6 @@ public sealed class ServerViewModel : ViewModelBase
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync); DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync);
} }
public event Func<Task>? ConnectRequested
{
add => _connectRequested = (Func<Task>?)Delegate.Combine(_connectRequested, value);
remove => _connectRequested = (Func<Task>?)Delegate.Remove(_connectRequested, value);
}
private Func<Task>? _connectRequested;
public event Func<Task>? DisconnectRequested
{
add => _disconnectRequested = (Func<Task>?)Delegate.Combine(_disconnectRequested, value);
remove => _disconnectRequested = (Func<Task>?)Delegate.Remove(_disconnectRequested, value);
}
private Func<Task>? _disconnectRequested;
public ServerModel Model { get; }
public string Name => Model.Name;
public string ServerUrl => Model.ServerUrl;
public ObservableCollection<ChannelViewModel> Channels { get; }
public ReactiveCommand<Unit, Unit> ConnectCommand { get; }
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
public ChannelViewModel? SelectedChannel
{
get => _selectedChannel;
set => this.RaiseAndSetIfChanged(ref _selectedChannel, value);
}
public bool IsExpanded
{
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() public void SyncFromModel()
{ {
IsConnected = Model.IsConnected; IsConnected = Model.IsConnected;
@@ -121,4 +133,20 @@ public sealed class ServerViewModel : ViewModelBase
ConnectedUser = Model.ConnectedUser; ConnectedUser = Model.ConnectedUser;
IsExpanded = true; IsExpanded = true;
} }
private async Task ConnectAsync()
{
if (_connectRequested is not null)
{
await _connectRequested();
}
}
private async Task DisconnectAsync()
{
if (_disconnectRequested is not null)
{
await _disconnectRequested();
}
}
} }
+16 -26
View File
@@ -1,44 +1,37 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Linq; using System.Reactive.Linq;
using Decho.Models;
using ReactiveUI;
namespace Decho.ViewModels; namespace Decho.ViewModels;
public sealed class SidebarViewModel : ViewModelBase public sealed class SidebarViewModel : ViewModelBase
{ {
private ChannelViewModel? _selectedChannel;
public SidebarViewModel()
{
Servers = new ObservableCollection<ServerViewModel>();
}
public ObservableCollection<ServerViewModel> Servers { get; } public ObservableCollection<ServerViewModel> Servers { get; }
public ChannelViewModel? SelectedChannel public ChannelViewModel? SelectedChannel
{ {
get => _selectedChannel; get;
set set
{ {
if (ReferenceEquals(_selectedChannel, value)) if (ReferenceEquals(field, value))
{
return; return;
}
this.RaiseAndSetIfChanged(ref _selectedChannel, value); _ = this.RaiseAndSetIfChanged(ref field, value);
if (value is null) if (value is null)
{
return; return;
}
foreach (var server in Servers) foreach (ServerViewModel server in Servers)
{ {
if (server.Channels.Contains(value)) if (server.Channels.Contains(value))
{ {
if (!ReferenceEquals(server.SelectedChannel, value)) if (!ReferenceEquals(server.SelectedChannel, value))
{
server.SelectedChannel = value; server.SelectedChannel = value;
}
} }
else if (server.SelectedChannel is not null) else if (server.SelectedChannel is not null)
{ {
@@ -48,26 +41,23 @@ public sealed class SidebarViewModel : ViewModelBase
} }
} }
public void AddServer(ServerModel model) public SidebarViewModel()
{ {
var vm = new ServerViewModel(model); Servers = [];
vm.WhenAnyValue(s => s.SelectedChannel)
.Where(channel => channel is not null)
.Subscribe(channel => SelectedChannel = channel!);
Servers.Add(vm);
} }
public void RemoveServer(string serverUrl) public void RemoveServer(string serverUrl)
{ {
var server = Servers.FirstOrDefault(s => ServerViewModel? server = Servers.FirstOrDefault(s =>
string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase)); string.Equals(s.ServerUrl, serverUrl, StringComparison.OrdinalIgnoreCase));
if (server is not null) if (server is not null)
{ {
if (SelectedChannel is not null && server.Channels.Contains(SelectedChannel)) if (SelectedChannel is not null && server.Channels.Contains(SelectedChannel))
{
SelectedChannel = null; SelectedChannel = null;
}
Servers.Remove(server); _ = Servers.Remove(server);
} }
} }
+2 -4
View File
@@ -1,7 +1,5 @@
using ReactiveUI; namespace Decho.ViewModels;
namespace Decho.ViewModels;
public abstract class ViewModelBase : ReactiveObject public abstract class ViewModelBase : ReactiveObject
{ {
} }
+1 -1
View File
@@ -8,4 +8,4 @@ public partial class ChannelListView : UserControl
{ {
InitializeComponent(); InitializeComponent();
} }
} }
+1 -1
View File
@@ -8,4 +8,4 @@ public partial class ChatView : UserControl
{ {
InitializeComponent(); InitializeComponent();
} }
} }
+22 -9
View File
@@ -1,8 +1,8 @@
using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Decho.ViewModels; using Decho.ViewModels;
namespace Decho.Views; namespace Decho.Views;
@@ -19,18 +19,24 @@ public partial class MessageComposerView : UserControl
private async void OnFileUploadClicked(object? sender, RoutedEventArgs e) private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not MessageComposerViewModel vm) return; if (DataContext is not MessageComposerViewModel vm)
{
return;
}
var topLevel = TopLevel.GetTopLevel(this); TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is null) return; if (topLevel?.StorageProvider is null)
{
return;
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions IReadOnlyList<IStorageFile> files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
AllowMultiple = false, AllowMultiple = false,
Title = "Select a file to upload", Title = "Select a file to upload",
}); });
var file = files?.FirstOrDefault(); IStorageFile? file = files?.FirstOrDefault();
if (file?.TryGetLocalPath() is string path) if (file?.TryGetLocalPath() is string path)
{ {
vm.RequestFileUpload(path); vm.RequestFileUpload(path);
@@ -41,21 +47,28 @@ public partial class MessageComposerView : UserControl
{ {
#pragma warning disable CS0618 #pragma warning disable CS0618
if (e.Data.Contains(DataFormats.Files)) if (e.Data.Contains(DataFormats.Files))
{
#pragma warning restore CS0618 #pragma warning restore CS0618
e.DragEffects = DragDropEffects.Copy; e.DragEffects = DragDropEffects.Copy;
}
} }
private void OnDrop(object? sender, DragEventArgs e) private void OnDrop(object? sender, DragEventArgs e)
{ {
if (DataContext is not MessageComposerViewModel vm) return; if (DataContext is not MessageComposerViewModel vm)
{
return;
}
#pragma warning disable CS0618 #pragma warning disable CS0618
var paths = e.Data.GetFiles()? string? paths = e.Data.GetFiles()?
.Select(f => f.TryGetLocalPath()) .Select(f => f.TryGetLocalPath())
.FirstOrDefault(p => p is not null); .FirstOrDefault(p => p is not null);
#pragma warning restore CS0618 #pragma warning restore CS0618
if (paths is string path) if (paths is string path)
{
vm.RequestFileUpload(path); vm.RequestFileUpload(path);
}
} }
} }
+54 -27
View File
@@ -1,10 +1,8 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Decho.ViewModels; using Decho.ViewModels;
namespace Decho.Views; namespace Decho.Views;
@@ -30,9 +28,13 @@ public partial class MessageItemView : UserControl
private void OnLoaded(object? sender, RoutedEventArgs e) private void OnLoaded(object? sender, RoutedEventArgs e)
{ {
if (!_pendingLoad) return; if (!_pendingLoad)
{
return;
}
_pendingLoad = false; _pendingLoad = false;
var msg = (MessageViewModel)DataContext!; MessageViewModel msg = (MessageViewModel)DataContext!;
_ = LoadImageAsync(msg, _loadCts!.Token); _ = LoadImageAsync(msg, _loadCts!.Token);
} }
@@ -40,21 +42,26 @@ public partial class MessageItemView : UserControl
{ {
try try
{ {
var topLevel = TopLevel.GetTopLevel(this); TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.DataContext is not MainWindowViewModel mainVm) return; if (topLevel?.DataContext is not MainWindowViewModel mainVm)
{
return;
}
var bytes = await mainVm.ConnectionService.DownloadImageBytesAsync( byte[]? bytes = await mainVm.ConnectionService.DownloadImageBytesAsync(
msg.ServerUrl ?? "", msg.AttachmentUrl!); msg.ServerUrl ?? "", msg.AttachmentUrl!);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (bytes is null || bytes.Length == 0) return; if (bytes is null || bytes.Length == 0)
{
return;
}
using var stream = new MemoryStream(bytes); using MemoryStream stream = new MemoryStream(bytes);
var bitmap = new Bitmap(stream); Bitmap bitmap = new Bitmap(stream);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var image = this.FindControl<Image>("MessageImage"); Image? image = this.FindControl<Image>("MessageImage");
if (image is not null) _ = image?.Source = bitmap;
image.Source = bitmap;
} }
catch catch
{ {
@@ -64,21 +71,35 @@ public partial class MessageItemView : UserControl
private async void OnDownloadClicked(object? sender, RoutedEventArgs e) private async void OnDownloadClicked(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not MessageViewModel msg) return; if (DataContext is not MessageViewModel msg)
if (msg.AttachmentUrl is null) return; {
return;
}
var topLevel = TopLevel.GetTopLevel(this); if (msg.AttachmentUrl is null)
if (topLevel?.StorageProvider is null) return; {
return;
}
var mainVm = topLevel.DataContext as MainWindowViewModel; TopLevel? topLevel = TopLevel.GetTopLevel(this);
if (mainVm is null) return; if (topLevel?.StorageProvider is null)
{
return;
}
var tempPath = await mainVm.ConnectionService.DownloadAttachmentAsync( if (topLevel.DataContext is not MainWindowViewModel mainVm)
msg.ServerUrl ?? "", msg.AttachmentUrl, msg.AttachmentFileName ?? "download"); {
return;
}
if (tempPath is null) return; string? tempPath = await mainVm.ConnectionService.DownloadAttachmentAsync(msg.ServerUrl ?? "", msg.AttachmentUrl, msg.AttachmentFileName ?? "download");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions if (tempPath is null)
{
return;
}
IStorageFile? file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{ {
SuggestedFileName = msg.AttachmentFileName ?? "download", SuggestedFileName = msg.AttachmentFileName ?? "download",
}); });
@@ -88,7 +109,13 @@ public partial class MessageItemView : UserControl
File.Copy(tempPath, savePath, overwrite: true); File.Copy(tempPath, savePath, overwrite: true);
} }
try { File.Delete(tempPath); } try
catch { } {
File.Delete(tempPath);
}
catch
{
// Ignore if temp file cannot be deleted
}
} }
} }
+19 -13
View File
@@ -1,8 +1,10 @@
using System.Collections.Specialized;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using Decho.ViewModels; using Decho.ViewModels;
using System.Collections.Specialized;
namespace Decho.Views; namespace Decho.Views;
public partial class MessageListView : UserControl public partial class MessageListView : UserControl
@@ -25,17 +27,15 @@ public partial class MessageListView : UserControl
_chat.PropertyChanged -= OnViewModelPropertyChanged; _chat.PropertyChanged -= OnViewModelPropertyChanged;
} }
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer"); ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
if (scroll is not null) scroll?.ScrollChanged -= OnScrollChanged;
scroll.ScrollChanged -= OnScrollChanged;
_chat = DataContext as ChatViewModel; _chat = DataContext as ChatViewModel;
if (_chat is not null) if (_chat is not null)
{ {
_chat.Messages.CollectionChanged += OnMessagesChanged; _chat.Messages.CollectionChanged += OnMessagesChanged;
_chat.PropertyChanged += OnViewModelPropertyChanged; _chat.PropertyChanged += OnViewModelPropertyChanged;
if (scroll is not null) scroll?.ScrollChanged += OnScrollChanged;
scroll.ScrollChanged += OnScrollChanged;
} }
} }
@@ -43,11 +43,11 @@ public partial class MessageListView : UserControl
{ {
if (e.ExtentDelta.Y > 0 && _wasAtBottom) if (e.ExtentDelta.Y > 0 && _wasAtBottom)
{ {
var scroll = (ScrollViewer)sender!; ScrollViewer scroll = (ScrollViewer)sender!;
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background); Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
} }
var scrollViewer = (ScrollViewer)sender!; ScrollViewer scrollViewer = (ScrollViewer)sender!;
_wasAtBottom = scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - 30; _wasAtBottom = scrollViewer.Offset.Y + scrollViewer.Viewport.Height >= scrollViewer.Extent.Height - 30;
} }
@@ -70,8 +70,11 @@ public partial class MessageListView : UserControl
private void TryAutoScroll() private void TryAutoScroll()
{ {
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer"); ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
if (scroll is null) return; if (scroll is null)
{
return;
}
_wasAtBottom = scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 30; _wasAtBottom = scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 30;
if (_wasAtBottom && !_hasPendingScroll) if (_wasAtBottom && !_hasPendingScroll)
@@ -87,10 +90,13 @@ public partial class MessageListView : UserControl
private void ScrollToBottom() private void ScrollToBottom()
{ {
var scroll = this.FindControl<ScrollViewer>("MessageScrollViewer"); ScrollViewer? scroll = this.FindControl<ScrollViewer>("MessageScrollViewer");
if (scroll is null) return; if (scroll is null)
{
return;
}
_wasAtBottom = true; _wasAtBottom = true;
Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background); Dispatcher.UIThread.Post(scroll.ScrollToEnd, DispatcherPriority.Background);
} }
} }
+6 -6
View File
@@ -12,14 +12,14 @@ public partial class SidebarView : UserControl
nameof(SelectedChannel), nameof(SelectedChannel),
defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); defaultBindingMode: Avalonia.Data.BindingMode.TwoWay);
public SidebarView()
{
InitializeComponent();
}
public ChannelViewModel? SelectedChannel public ChannelViewModel? SelectedChannel
{ {
get => GetValue(SelectedChannelProperty); get => GetValue(SelectedChannelProperty);
set => SetValue(SelectedChannelProperty, value); set => SetValue(SelectedChannelProperty, value);
} }
}
public SidebarView()
{
InitializeComponent();
}
}