diff --git a/src/Decho/App.axaml.cs b/src/Decho/App.axaml.cs index 1ab8811..9d81b86 100644 --- a/src/Decho/App.axaml.cs +++ b/src/Decho/App.axaml.cs @@ -2,9 +2,16 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using Decho.Services; using Decho.ViewModels; using Decho.Views; +using EchoHub.Client.Commands; +using EchoHub.Client.Config; +using EchoHub.Client.Services; + +using Splat; + namespace Decho; public partial class App : Application @@ -20,7 +27,9 @@ public partial class App : Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - _viewModel = new MainWindowViewModel(); + RegisterDependencies(); + + _viewModel = Locator.Current.GetService()!; desktop.MainWindow = new MainWindow { @@ -34,4 +43,39 @@ public partial class App : Application base.OnFrameworkInitializationCompleted(); } -} \ No newline at end of file + + private static void RegisterDependencies() + { + ConnectionService connectionService = new(); + Locator.CurrentMutable.RegisterLazySingleton(() => connectionService); + + IConnectionStore store = connectionService.Store; + Locator.CurrentMutable.RegisterLazySingleton(() => store); + ICryptoService crypto = new CryptoService(store); + Locator.CurrentMutable.RegisterLazySingleton(() => crypto); + + IChannelService channelService = new ChannelService(store, crypto); + Locator.CurrentMutable.RegisterLazySingleton(() => channelService); + + IUserService userService = new UserService(store); + Locator.CurrentMutable.RegisterLazySingleton(() => userService); + + IInviteService inviteService = new InviteService(store); + Locator.CurrentMutable.RegisterLazySingleton(() => inviteService); + + CommandHandler commandHandler = new CommandHandler(); + Locator.CurrentMutable.RegisterLazySingleton(() => commandHandler); + + NotificationSoundService notificationService = new(ConfigManager.Load().Notifications); + Locator.CurrentMutable.RegisterLazySingleton(() => notificationService); + + Locator.CurrentMutable.Register(() => + new MainWindowViewModel( + Locator.Current.GetService()!, + Locator.Current.GetService()!, + Locator.Current.GetService()!, + Locator.Current.GetService()!, + Locator.Current.GetService()!, + Locator.Current.GetService()!)); + } +} diff --git a/src/Decho/Services/AttachmentService.cs b/src/Decho/Services/AttachmentService.cs new file mode 100644 index 0000000..da0367c --- /dev/null +++ b/src/Decho/Services/AttachmentService.cs @@ -0,0 +1,100 @@ +using EchoHub.Core.Security; + +using System.Diagnostics; + +namespace Decho.Services; + +internal sealed class AttachmentService : IAttachmentService +{ + private readonly IConnectionStore _store; + + public AttachmentService(IConnectionStore store) + { + _store = store; + } + + public async Task DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) + { + return null; + } + + try + { + string? tempPath = await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, fileName); + if (tempPath is null) + { + return null; + } + + if (entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey)) + { + try + { + byte[] encrypted = await File.ReadAllBytesAsync(tempPath); + await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(encrypted, roomKey)); + } + catch (Exception ex) + { + Debug.WriteLine($"Decrypt failed for attachment: {ex.Message}"); + } + } + + return tempPath; + } + catch (Exception ex) + { + Debug.WriteLine($"Download failed: {ex.Message}"); + return null; + } + } + + public async Task DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) + { + 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 (Exception ex) + { + Debug.WriteLine($"Temp file deletion failed: {ex.Message}"); + } + + if (entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey)) + { + try + { + bytes = RoomCrypto.DecryptBytes(bytes, roomKey); + } + catch (Exception ex) + { + Debug.WriteLine($"Decrypt failed for image: {ex.Message}"); + } + } + + return bytes; + } + catch (Exception ex) + { + Debug.WriteLine($"Image download failed: {ex.Message}"); + return null; + } + } +} diff --git a/src/Decho/Services/ChannelService.cs b/src/Decho/Services/ChannelService.cs new file mode 100644 index 0000000..27d1360 --- /dev/null +++ b/src/Decho/Services/ChannelService.cs @@ -0,0 +1,344 @@ +using Decho.Models; + +using EchoHub.Client.Services; + +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Security; +using EchoHub.Core.Services; + +using System.Diagnostics; + +namespace Decho.Services; + +internal sealed class ChannelService : IChannelService +{ + private readonly IConnectionStore _store; + private readonly ICryptoService _crypto; + + public ChannelService(IConnectionStore store, ICryptoService crypto) + { + _store = store; + _crypto = crypto; + } + + public async Task JoinWithCryptoAsync(string serverUrl, string channelName, string? password) + { + ChannelCryptoDto? crypto = await GetChannelCryptoAsync(serverUrl, channelName); + bool isEncrypted = crypto is not null && crypto.IsEncrypted; + + if (isEncrypted) + { + MarkChannelEncrypted(serverUrl, channelName, true); + } + + string? wirePassword = DeriveWirePassword(password, crypto); + ChannelJoinResult result = await JoinChannelAsync(serverUrl, channelName, wirePassword); + + if (isEncrypted && !HasChannelKey(serverUrl, channelName) && password is not null) + { + try + { + ChannelJoinResult unlockResult = await UnlockRoomKeyAsync( + serverUrl, channelName, password, crypto!.EncryptionSalt!, result.WrappedRoomKey ?? ""); + if (unlockResult.History.Count > 0) + { + result = unlockResult; + } + } + catch (Exception ex) + { + Debug.WriteLine($"Unlock failed: {ex.Message}"); + } + } + + return result; + } + + public async Task CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + string? wirePassword = null, saltB64 = null, wrappedKey = null; + + if (password is not null) + { + byte[] salt = RoomCrypto.GenerateSalt(); + RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(password, salt); + byte[] roomKey = RoomCrypto.GenerateRoomKey(); + wirePassword = derived.AuthKeyHex; + saltB64 = Convert.ToBase64String(salt); + wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey); + entry.Manager.RoomKeys.StoreKey(name, roomKey); + } + + return await entry.ApiClient.CreateChannelAsync(name, topic, isPublic, wirePassword, saltB64, wrappedKey); + } + + public async Task JoinChannelAsync(string serverUrl, string channelName, string? password = null) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + if (entry.Manager.TrackChannel(channelName)) + { + JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); + + List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); + + bool isEncrypted = !string.IsNullOrEmpty(outcome.EncryptionSalt) && !string.IsNullOrEmpty(outcome.WrappedRoomKey); + bool hasKey = entry.Manager.RoomKeys.HasKey(channelName); + + return new ChannelJoinResult(history, isEncrypted && !hasKey, outcome.EncryptionSalt, outcome.WrappedRoomKey); + } + + bool encFlag = entry.Manager.RoomKeys.IsChannelEncrypted(channelName); + bool hasKeyFlag = entry.Manager.RoomKeys.HasKey(channelName); + + if (encFlag && !hasKeyFlag) + { + JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); + List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); + return new ChannelJoinResult(history, true, outcome.EncryptionSalt, outcome.WrappedRoomKey); + } + + List existing = await entry.Manager.GetHistoryAsync(channelName); + List hist = existing.Select(m => MessageModelFromDto(m, entry)).ToList(); + + return new ChannelJoinResult(hist, encFlag && !hasKeyFlag, null, null); + } + + public async Task LeaveChannelAsync(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.Manager.LeaveChannelAsync(channelName); + entry.Manager.UntrackChannel(channelName); + } + + public async Task DeleteChannelAsync(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + await entry.ApiClient.DeleteChannelAsync(channelName); + entry.Manager.UntrackChannel(channelName); + } + + public async Task NukeChannelAsync(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.NukeChannelAsync(channelName); + } + + public void UpdateChannelTopic(string serverUrl, string channelName, string? topic) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + ChannelModel? channel = entry.Server.Channels.FirstOrDefault(c => + string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase)); + if (channel is not null) + { + channel.Topic = topic; + } + } + + public async Task GetChannelCryptoAsync(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return null; + + return await entry.ApiClient.GetChannelCryptoAsync(channelName); + } + + public async Task UnlockRoomKeyAsync(string serverUrl, string channelName, string passphrase, string encryptionSalt, string wrappedRoomKey) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + if (!entry.Manager.RoomKeys.IsChannelEncrypted(channelName)) + { + return new ChannelJoinResult([], false, null, null); + } + + if (string.IsNullOrEmpty(encryptionSalt)) + { + throw new InvalidOperationException("Encryption salt not available for this channel"); + } + + if (string.IsNullOrEmpty(wrappedRoomKey)) + { + throw new InvalidOperationException("Wrapped room key not available. Re-join the channel to obtain it."); + } + + byte[] salt = Convert.FromBase64String(encryptionSalt); + RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(passphrase, salt); + + if (!entry.Manager.RoomKeys.TryStoreFromEnvelope(channelName, wrappedRoomKey, derived.KeyEncryptionKey)) + { + throw new InvalidOperationException("Wrong passphrase"); + } + + List history = await entry.Manager.GetHistoryAsync(channelName); + return new ChannelJoinResult(history.Select(m => MessageModelFromDto(m, entry)).ToList(), false, null, null); + } + + public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted) + => _crypto.MarkChannelEncrypted(serverUrl, channelName, isEncrypted); + + public bool HasChannelKey(string serverUrl, string channelName) + => _crypto.HasChannelKey(serverUrl, channelName); + + public void AddChannelToList(string serverUrl, ChannelDto channelDto) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + ChannelModel channel = new ChannelModel( + channelDto.Id.ToString(), + channelDto.Name, + [], + channelDto.Topic, + channelDto.IsPublic, + channelDto.IsProtected); + entry.Server.Channels.Add(channel); + } + + public void RemoveChannelFromList(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) 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); + } + } + + public async Task> GetChannelsAsync(string serverUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return []; + + return await entry.ApiClient.GetChannelsAsync(); + } + + public async Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + await entry.Manager.SendMessageAsync(channelName, content, replyToMessageId); + } + + public async Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList filePaths, string? size = null) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected to server"); + + entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey); + List attachments = new List(filePaths.Count); + + foreach (string filePath in filePaths) + { + OutgoingAttachment attachment = await BuildAttachmentAsync(filePath, roomKey, size); + attachments.Add(attachment); + } + + _ = await entry.ApiClient.SendMessageWithAttachmentsAsync(channelName, content, attachments, size); + } + + public async Task UploadFileAsync(string serverUrl, string channelName, string filePath, string? size) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey); + OutgoingAttachment attachment = await BuildAttachmentAsync(filePath, roomKey, size); + + _ = await entry.ApiClient.SendMessageWithAttachmentsAsync(channelName, "", [attachment], size); + } + + public async Task SendUrlAsync(string serverUrl, string channelName, string url, string? size) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + _ = await entry.ApiClient.SendUrlAsync(channelName, url, size); + } + + public async Task> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return []; + + List messages = await entry.Manager.GetHistoryAsync(channelName, count, offset); + return messages.Select(m => MessageModelFromDto(m, entry)).ToList(); + } + + private static async Task BuildAttachmentAsync(string filePath, byte[]? roomKey, string? size) + { + string fileName = Path.GetFileName(filePath); + byte[] bytes = await File.ReadAllBytesAsync(filePath); + + if (roomKey is null || roomKey.Length == 0) + { + return new OutgoingAttachment(new MemoryStream(bytes), fileName); + } + + string declaredKind; + string? preview = null; + + await using (MemoryStream ms = new MemoryStream(bytes)) + { + if (FileValidationHelper.IsValidImage(ms)) + { + declaredKind = "image"; + (int w, int h) = ImageToAsciiService.GetDimensions(size); + ms.Position = 0; + preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); + } + else + { + declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; + } + } + + byte[] encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); + return new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); + } + + private static string? DeriveWirePassword(string? password, ChannelCryptoDto? crypto) + { + if (password is null || crypto?.EncryptionSalt is null) return password; + byte[] salt = Convert.FromBase64String(crypto.EncryptionSalt); + return RoomCrypto.DeriveKeys(password, salt).AuthKeyHex; + } + + private static MessageModel MessageModelFromDto(MessageDto dto, ServerConnection entry) + { + UserModel author = new UserModel( + dto.SenderUsername, + dto.SenderDisplayName ?? dto.SenderUsername, + dto.SenderNicknameColor); + + List attachments = dto.Attachments ?? []; + + return new MessageModel( + dto.Id.ToString("N"), + author, + dto.SentAt, + dto.Content, + dto.ChannelName, + entry.Server.ServerUrl, + attachments, + dto.ReplyTo); + } +} diff --git a/src/Decho/Services/ConfigPersistenceService.cs b/src/Decho/Services/ConfigPersistenceService.cs new file mode 100644 index 0000000..1f14046 --- /dev/null +++ b/src/Decho/Services/ConfigPersistenceService.cs @@ -0,0 +1,71 @@ +using EchoHub.Client.Config; + +namespace Decho.Services; + +internal sealed class ConfigPersistenceService : IConfigPersistenceService +{ + public void SaveRefreshToken(string serverUrl, string token, string userId) + { + ModifyConfig(serverUrl, (config, saved) => + { + if (saved is null) + { + saved = new SavedServer + { + Name = new Uri(serverUrl).Host, + Url = serverUrl, + Username = userId, + RememberMe = true, + LastConnected = DateTimeOffset.Now, + }; + config.SavedServers.Add(saved); + } + + saved.RefreshToken = token; + saved.LastConnected = DateTimeOffset.Now; + }); + } + + public void RemoveServerFromConfig(string serverUrl) + { + ModifyConfig(serverUrl, (config, saved) => + { + if (saved is not null) + { + _ = config.SavedServers.Remove(saved); + } + }); + } + + public void RemoveFromLeftChannels(string serverUrl, string channelName) + { + ClientConfig config = ConfigManager.Load(); + SavedServer? saved = config.SavedServers + .FirstOrDefault(s => string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (saved is not null && saved.LeftChannels.Remove(channelName)) + { + ConfigManager.Save(config); + } + } + + public void AddLeftChannel(string serverUrl, string channelName) + { + ClientConfig config = ConfigManager.Load(); + SavedServer? saved = config.SavedServers + .FirstOrDefault(s => string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + if (saved is not null && !saved.LeftChannels.Contains(channelName, StringComparer.OrdinalIgnoreCase)) + { + saved.LeftChannels.Add(channelName); + ConfigManager.Save(config); + } + } + + private static void ModifyConfig(string serverUrl, Action action) + { + ClientConfig config = ConfigManager.Load(); + SavedServer? saved = config.SavedServers.FirstOrDefault(s => + string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); + action(config, saved); + ConfigManager.Save(config); + } +} diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index ad4ea20..6fea1cd 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -6,16 +6,13 @@ using EchoHub.Client.UI.Dialogs; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; -using EchoHub.Core.Security; using EchoHub.Core.Services; using System.Collections.ObjectModel; +using System.Diagnostics; namespace Decho.Services; -/// -/// Result of joining a channel, including E2EE encryption metadata if the channel is encrypted. -/// public sealed class ChannelJoinResult { public List History { get; } @@ -32,30 +29,43 @@ public sealed class ChannelJoinResult } } -public sealed class ConnectionService : IDisposable +public sealed class ConnectionService : IConnectionService { public event Action? ServerAdded; - public event Action? ServerRemoved; - public event Action? ServerStateChanged; - public event Action? ChannelAdded; - public event Action? ChannelRemoved; - public event Action? MessageReceived; - public event Action? UserJoined; - public event Action? UserLeft; - public event Action? ErrorOccurred; - public event Action? ChannelDeleted; private readonly Dictionary _connections = new(StringComparer.OrdinalIgnoreCase); + private readonly IConfigPersistenceService _config; + private readonly IAttachmentService _attachment; + private readonly ConnectionStore _store; + private readonly IChannelService _channelService; + internal IReadOnlyDictionary Connections => _connections; + internal IConnectionStore Store => _store; + + public ConnectionService() + : this(new ConfigPersistenceService()) + { + } + + internal ConnectionService(IConfigPersistenceService config) + { + _config = config; + _store = new ConnectionStore(_connections); + ICryptoService crypto = new CryptoService(_store); + _attachment = new AttachmentService(_store); + _channelService = new ChannelService(_store, crypto); + } + + // ── Connection lifecycle ────────────────────────────────────────────── public async Task ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe) { @@ -87,517 +97,7 @@ public sealed class ConnectionService : IDisposable ServerRemoved?.Invoke(serverUrl); } - public async Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected to server"); - } - - await entry.Manager.SendMessageAsync(channelName, content, replyToMessageId); - } - - public async Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList filePaths, string? size = null) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected to server"); - } - - entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey); - List attachments = new List(filePaths.Count); - - foreach (string filePath in filePaths) - { - string fileName = Path.GetFileName(filePath); - OutgoingAttachment attachment; - - if (roomKey is not null && roomKey.Length > 0) - { - byte[] bytes = await File.ReadAllBytesAsync(filePath); - string declaredKind; - string? preview = null; - - await using (MemoryStream ms = new MemoryStream(bytes)) - { - if (FileValidationHelper.IsValidImage(ms)) - { - declaredKind = "image"; - (int w, int h) = ImageToAsciiService.GetDimensions(size); - ms.Position = 0; - preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); - } - else - { - declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; - } - } - - byte[] encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); - attachment = new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); - } - else - { - byte[] bytes = await File.ReadAllBytesAsync(filePath); - attachment = new OutgoingAttachment(new MemoryStream(bytes), fileName); - } - - attachments.Add(attachment); - } - - _ = await entry.ApiClient.SendMessageWithAttachmentsAsync(channelName, content, attachments, size); - } - - public async Task CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected to server"); - } - - string? wirePassword = null, saltB64 = null, wrappedKey = null; - - if (password is not null) - { - byte[] salt = RoomCrypto.GenerateSalt(); - RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(password, salt); - byte[] roomKey = RoomCrypto.GenerateRoomKey(); - wirePassword = derived.AuthKeyHex; - saltB64 = Convert.ToBase64String(salt); - wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey); - // Store the room key locally so we can decrypt messages immediately - entry.Manager.RoomKeys.StoreKey(name, roomKey); - } - - ChannelDto? channel = await entry.ApiClient.CreateChannelAsync(name, topic, isPublic, wirePassword, saltB64, wrappedKey); - return channel; - } - - public async Task JoinChannelAsync(string serverUrl, string channelName, string? password = null) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected to server"); - } - - if (entry.Manager.TrackChannel(channelName)) - { - JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); - RemoveFromLeftChannels(serverUrl, channelName); - - List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); - - bool isEncrypted = !string.IsNullOrEmpty(outcome.EncryptionSalt) && !string.IsNullOrEmpty(outcome.WrappedRoomKey); - bool hasKey = entry.Manager.RoomKeys.HasKey(channelName); - - return new ChannelJoinResult(history, isEncrypted && !hasKey, outcome.EncryptionSalt, outcome.WrappedRoomKey); - } - - RemoveFromLeftChannels(serverUrl, channelName); - - // For E2EE channels where the key hasn't been stored yet, always do a real join - // to obtain the wrapped room key (TrackChannel returned false on re-selection, - // so the first branch above skipped the actual join). - bool encFlag = entry.Manager.RoomKeys.IsChannelEncrypted(channelName); - bool hasKeyFlag = entry.Manager.RoomKeys.HasKey(channelName); - - if (encFlag && !hasKeyFlag) - { - JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); - List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); - return new ChannelJoinResult(history, true, outcome.EncryptionSalt, outcome.WrappedRoomKey); - } - - List existing = await entry.Manager.GetHistoryAsync(channelName); - List hist = existing.Select(m => MessageModelFromDto(m, entry)).ToList(); - - return new ChannelJoinResult(hist, encFlag && !hasKeyFlag, null, null); - } - - public async Task UnlockRoomKeyAsync(string serverUrl, string channelName, string passphrase, string encryptionSalt, string wrappedRoomKey) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected to server"); - } - - if (!entry.Manager.RoomKeys.IsChannelEncrypted(channelName)) - { - return new ChannelJoinResult([], false, null, null); - } - - if (string.IsNullOrEmpty(encryptionSalt)) - { - throw new InvalidOperationException("Encryption salt not available for this channel"); - } - - if (string.IsNullOrEmpty(wrappedRoomKey)) - { - throw new InvalidOperationException("Wrapped room key not available. Re-join the channel to obtain it."); - } - - byte[] salt = Convert.FromBase64String(encryptionSalt); - RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(passphrase, salt); - - if (!entry.Manager.RoomKeys.TryStoreFromEnvelope(channelName, wrappedRoomKey, derived.KeyEncryptionKey)) - { - throw new InvalidOperationException("Wrong passphrase"); - } - - // Fetch fresh history now that the key is available - List history = await entry.Manager.GetHistoryAsync(channelName); - return new ChannelJoinResult(history.Select(m => MessageModelFromDto(m, entry)).ToList(), false, null, null); - } - - public async Task LeaveChannelAsync(string serverUrl, string channelName) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return; - } - - await entry.Manager.LeaveChannelAsync(channelName); - - ClientConfig config = ConfigManager.Load(); - SavedServer? saved = config.SavedServers - .FirstOrDefault(s => string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); - if (saved is not null && !saved.LeftChannels.Contains(channelName, StringComparer.OrdinalIgnoreCase)) - { - saved.LeftChannels.Add(channelName); - ConfigManager.Save(config); - } - } - - public async Task> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return []; - } - - List history = await entry.Manager.GetHistoryAsync(channelName, count, offset); - return history.Select(m => MessageModelFromDto(m, entry)).ToList(); - } - - public async Task> GetOnlineUsersAsync(string serverUrl, string channelName) - { - if (!_connections.TryGetValue(serverUrl, out 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 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> GetChannelsAsync(string serverUrl) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return []; - } - - return await entry.ApiClient.GetChannelsAsync(); - } - - public async Task GetChannelCryptoAsync(string serverUrl, string channelName) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return null; - } - - return await entry.ApiClient.GetChannelCryptoAsync(channelName); - } - - // ── Invites / Account ──────────────────────────────────────────────── - - public async Task CreateInviteAsync(string serverUrl, int? maxUses, int? expiresInHours) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return null; - } - - return await entry.ApiClient.CreateInviteAsync(maxUses, expiresInHours); - } - - public async Task> GetInvitesAsync(string serverUrl) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return []; - } - - return await entry.ApiClient.GetInvitesAsync(); - } - - public async Task RevokeInviteAsync(string serverUrl, string code) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return; - } - - await entry.ApiClient.RevokeInviteAsync(code); - } - - public async Task ExportMyDataAsync(string serverUrl) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected"); - } - - return await entry.ApiClient.ExportMyDataAsync(); - } - - public async Task DeleteMyAccountAsync(string serverUrl, string password) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - throw new InvalidOperationException("Not connected"); - } - - await entry.ApiClient.DeleteMyAccountAsync(password); - } - - public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return; - } - - entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, isEncrypted); - } - - 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 GetUserProfileAsync(string serverUrl, string username) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return null; - } - - return await entry.ApiClient.GetUserProfileAsync(username); - } - - 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; - } - - string fileName = Path.GetFileName(filePath); - OutgoingAttachment attachment; - - if (entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey)) - { - // End-to-end encrypted channel: encrypt the blob locally, declare its kind, - // and room-encrypt an image ASCII preview — the server stores the ciphertext as-is. - byte[] bytes = await File.ReadAllBytesAsync(filePath); - string declaredKind; - string? preview = null; - - await using (MemoryStream ms = new MemoryStream(bytes)) - { - if (FileValidationHelper.IsValidImage(ms)) - { - declaredKind = "image"; - (int w, int h) = ImageToAsciiService.GetDimensions(size); - ms.Position = 0; - preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); - } - else - { - declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; - } - } - - byte[] encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); - attachment = new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); - } - else - { - await using FileStream stream = File.OpenRead(filePath); - attachment = new OutgoingAttachment(stream, fileName); - } - - _ = await entry.ApiClient.SendMessageWithAttachmentsAsync(channelName, "", [attachment], 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); - } - } + // ── Non-delegated helpers ───────────────────────────────────────────── public string? GetRefreshToken(string serverUrl) { @@ -607,92 +107,10 @@ public sealed class ConnectionService : IDisposable } public async Task DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName) - { - if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) - { - return null; - } - - try - { - string? tempPath = await entry.ApiClient.DownloadFileToTempAsync(relativeUrl, fileName); - if (tempPath is null) - { - return null; - } - - if (entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey)) - { - try - { - byte[] encrypted = await File.ReadAllBytesAsync(tempPath); - await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(encrypted, roomKey)); - } - catch - { - // Not room ciphertext — leave the downloaded bytes as-is. - } - } - - return tempPath; - } - catch - { - return null; - } - } + => await _attachment.DownloadAttachmentAsync(serverUrl, channelName, relativeUrl, fileName); public async Task DownloadImageBytesAsync(string serverUrl, string channelName, 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 - } - - if (entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey)) - { - try - { - bytes = RoomCrypto.DecryptBytes(bytes, roomKey); - } - catch - { - // Not room ciphertext — leave the downloaded bytes as-is. - } - } - - return bytes; - } - catch - { - return null; - } - } - - public string? GetCurrentUsername(string serverUrl) - { - return _connections.TryGetValue(serverUrl, out ServerConnection? entry) - ? entry.User.DisplayName - : null; - } + => await _attachment.DownloadImageBytesAsync(serverUrl, channelName, relativeUrl); public void Dispose() { @@ -703,6 +121,8 @@ public sealed class ConnectionService : IDisposable _connections.Clear(); } + // ── Internal helpers ────────────────────────────────────────────────── + internal static ChannelModel ChannelModelFromDto(ChannelDto dto) { return new ChannelModel( @@ -739,35 +159,14 @@ public sealed class ConnectionService : IDisposable return _connections.TryGetValue(serverUrl, out ServerConnection? conn) ? conn : null; } - private static void ModifyConfig(string serverUrl, Action action) + private void RemoveServerFromConfig(string serverUrl) { - ClientConfig config = ConfigManager.Load(); - SavedServer? saved = config.SavedServers.FirstOrDefault(s => - string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); - action(config, saved); - ConfigManager.Save(config); + _config.RemoveServerFromConfig(serverUrl); } - private static void RemoveServerFromConfig(string serverUrl) + private void RemoveFromLeftChannels(string serverUrl, string channelName) { - ModifyConfig(serverUrl, (config, saved) => - { - if (saved is not null) - { - _ = config.SavedServers.Remove(saved); - } - }); - } - - private static void RemoveFromLeftChannels(string serverUrl, string channelName) - { - ClientConfig config = ConfigManager.Load(); - SavedServer? saved = config.SavedServers - .FirstOrDefault(s => string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase)); - if (saved is not null && saved.LeftChannels.Remove(channelName)) - { - ConfigManager.Save(config); - } + _config.RemoveFromLeftChannels(serverUrl, channelName); } private async Task ConnectCoreAsync(ConnectDialogResult dialogResult) @@ -837,15 +236,15 @@ public sealed class ConnectionService : IDisposable try { - _ = await JoinChannelAsync(serverUrl, ch.Name); + _ = await _channelService.JoinChannelAsync(serverUrl, ch.Name); + RemoveFromLeftChannels(serverUrl, ch.Name); } catch (EchoHub.Client.Services.ChannelPasswordRequiredException) { - // protected channel — join stays manual } - catch + catch (Exception ex) { - // skip channels we can't join + Debug.WriteLine($"Auto-join failed for {ch.Name}: {ex.Message}"); } } } @@ -886,24 +285,7 @@ public sealed class ConnectionService : IDisposable return; } - ModifyConfig(serverUrl, (config, saved) => - { - if (saved is null) - { - saved = new SavedServer - { - Name = new Uri(serverUrl).Host, - Url = serverUrl, - Username = entry.User.Id, - RememberMe = true, - LastConnected = DateTimeOffset.Now, - }; - config.SavedServers.Add(saved); - } - - saved.RefreshToken = token; - saved.LastConnected = DateTimeOffset.Now; - }); + _config.SaveRefreshToken(serverUrl, token, entry.User.Id); } private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn) @@ -977,19 +359,3 @@ public sealed class ConnectionService : IDisposable }; } } - -internal sealed class ServerConnection -{ - public ApiClient ApiClient { get; } - public ServerModel Server { get; } - public UserModel User { get; } - internal ConnectionManager Manager { get; } - - internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user) - { - Manager = manager; - ApiClient = apiClient; - Server = server; - User = user; - } -} \ No newline at end of file diff --git a/src/Decho/Services/ConnectionStore.cs b/src/Decho/Services/ConnectionStore.cs new file mode 100644 index 0000000..ca20737 --- /dev/null +++ b/src/Decho/Services/ConnectionStore.cs @@ -0,0 +1,14 @@ +namespace Decho.Services; + +internal sealed class ConnectionStore : IConnectionStore +{ + private readonly Dictionary _connections; + + public ConnectionStore(Dictionary connections) + { + _connections = connections; + } + + public ServerConnection? Get(string serverUrl) + => _connections.TryGetValue(serverUrl, out ServerConnection? entry) ? entry : null; +} diff --git a/src/Decho/Services/CryptoService.cs b/src/Decho/Services/CryptoService.cs new file mode 100644 index 0000000..51ea367 --- /dev/null +++ b/src/Decho/Services/CryptoService.cs @@ -0,0 +1,40 @@ +namespace Decho.Services; + +internal sealed class CryptoService : ICryptoService +{ + private readonly IConnectionStore _store; + + public CryptoService(IConnectionStore store) + { + _store = store; + } + + public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) + { + return; + } + + entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, isEncrypted); + } + + public bool HasChannelKey(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + return entry is not null && entry.Manager.RoomKeys.HasKey(channelName); + } + + public bool TryGetRoomKey(string serverUrl, string channelName, out byte[]? key) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) + { + key = null; + return false; + } + + return entry.Manager.RoomKeys.TryGetKey(channelName, out key); + } +} diff --git a/src/Decho/Services/IAttachmentService.cs b/src/Decho/Services/IAttachmentService.cs new file mode 100644 index 0000000..0b25099 --- /dev/null +++ b/src/Decho/Services/IAttachmentService.cs @@ -0,0 +1,7 @@ +namespace Decho.Services; + +public interface IAttachmentService +{ + Task DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName); + Task DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl); +} diff --git a/src/Decho/Services/IChannelService.cs b/src/Decho/Services/IChannelService.cs new file mode 100644 index 0000000..e1d416d --- /dev/null +++ b/src/Decho/Services/IChannelService.cs @@ -0,0 +1,48 @@ +using Decho.Models; + +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Core.Security; + +namespace Decho.Services; + +public interface IChannelService +{ + Task JoinWithCryptoAsync(string serverUrl, string channelName, string? password); + + Task CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null); + + Task JoinChannelAsync(string serverUrl, string channelName, string? password = null); + + Task LeaveChannelAsync(string serverUrl, string channelName); + + Task DeleteChannelAsync(string serverUrl, string channelName); + + Task NukeChannelAsync(string serverUrl, string channelName); + + void UpdateChannelTopic(string serverUrl, string channelName, string? topic); + + Task GetChannelCryptoAsync(string serverUrl, string channelName); + + Task UnlockRoomKeyAsync(string serverUrl, string channelName, string passphrase, string encryptionSalt, string wrappedRoomKey); + + void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted); + + bool HasChannelKey(string serverUrl, string channelName); + + void AddChannelToList(string serverUrl, ChannelDto channelDto); + + void RemoveChannelFromList(string serverUrl, string channelName); + + Task> GetChannelsAsync(string serverUrl); + + Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null); + + Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList filePaths, string? size = null); + + Task UploadFileAsync(string serverUrl, string channelName, string filePath, string? size); + + Task SendUrlAsync(string serverUrl, string channelName, string url, string? size); + + Task> GetHistoryAsync(string serverUrl, string channelName, int count = 50, int offset = 0); +} diff --git a/src/Decho/Services/IConfigPersistenceService.cs b/src/Decho/Services/IConfigPersistenceService.cs new file mode 100644 index 0000000..5586802 --- /dev/null +++ b/src/Decho/Services/IConfigPersistenceService.cs @@ -0,0 +1,9 @@ +namespace Decho.Services; + +public interface IConfigPersistenceService +{ + void SaveRefreshToken(string serverUrl, string token, string userId); + void RemoveServerFromConfig(string serverUrl); + void RemoveFromLeftChannels(string serverUrl, string channelName); + void AddLeftChannel(string serverUrl, string channelName); +} diff --git a/src/Decho/Services/IConnectionService.cs b/src/Decho/Services/IConnectionService.cs new file mode 100644 index 0000000..b1d1f52 --- /dev/null +++ b/src/Decho/Services/IConnectionService.cs @@ -0,0 +1,30 @@ +using Decho.Models; + +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + +namespace Decho.Services; + +public interface IConnectionService : IDisposable +{ + event Action? ServerAdded; + event Action? ServerRemoved; + event Action? ServerStateChanged; + event Action? ChannelAdded; + event Action? ChannelRemoved; + event Action? MessageReceived; + event Action? UserJoined; + event Action? UserLeft; + event Action? ErrorOccurred; + event Action? ChannelDeleted; + + Task ConnectAsync(string serverUrl, string username, string password, bool isRegister, bool rememberMe); + Task ConnectWithSavedTokenAsync(string serverUrl, string username, string refreshToken, bool rememberMe); + Task DisconnectAsync(string serverUrl); + Task RemoveServerAsync(string serverUrl); + + string? GetRefreshToken(string serverUrl); + + Task DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName); + Task DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl); +} diff --git a/src/Decho/Services/IConnectionStore.cs b/src/Decho/Services/IConnectionStore.cs new file mode 100644 index 0000000..a68b4f9 --- /dev/null +++ b/src/Decho/Services/IConnectionStore.cs @@ -0,0 +1,6 @@ +namespace Decho.Services; + +internal interface IConnectionStore +{ + ServerConnection? Get(string serverUrl); +} diff --git a/src/Decho/Services/ICryptoService.cs b/src/Decho/Services/ICryptoService.cs new file mode 100644 index 0000000..c78593b --- /dev/null +++ b/src/Decho/Services/ICryptoService.cs @@ -0,0 +1,8 @@ +namespace Decho.Services; + +public interface ICryptoService +{ + void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted); + bool HasChannelKey(string serverUrl, string channelName); + bool TryGetRoomKey(string serverUrl, string channelName, out byte[]? key); +} diff --git a/src/Decho/Services/IInviteService.cs b/src/Decho/Services/IInviteService.cs new file mode 100644 index 0000000..6df50fd --- /dev/null +++ b/src/Decho/Services/IInviteService.cs @@ -0,0 +1,12 @@ +using EchoHub.Core.DTOs; + +namespace Decho.Services; + +public interface IInviteService +{ + Task CreateInviteAsync(string serverUrl, int? maxUses, int? expiresInHours); + + Task> GetInvitesAsync(string serverUrl); + + Task RevokeInviteAsync(string serverUrl, string code); +} diff --git a/src/Decho/Services/IUserService.cs b/src/Decho/Services/IUserService.cs new file mode 100644 index 0000000..f9bc574 --- /dev/null +++ b/src/Decho/Services/IUserService.cs @@ -0,0 +1,37 @@ +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + +namespace Decho.Services; + +public interface IUserService +{ + Task UpdateStatusAsync(string serverUrl, UserStatus status, string? statusMessage); + + Task UpdateProfileAsync(string serverUrl, string? displayName, string? bio, string? nickColor); + + Task SetAvatarAsync(string serverUrl, string target); + + Task GetUserProfileAsync(string serverUrl, string username); + + Task> GetOnlineUsersAsync(string serverUrl, string channelName); + + Task KickUserAsync(string serverUrl, string username, string? reason); + + Task BanUserAsync(string serverUrl, string username, string? reason); + + Task UnbanUserAsync(string serverUrl, string username); + + Task MuteUserAsync(string serverUrl, string username, int? durationMinutes); + + Task UnmuteUserAsync(string serverUrl, string username); + + Task AssignRoleAsync(string serverUrl, string username, ServerRole role); + + string? GetCurrentUsername(string serverUrl); + + Task DeleteMyAccountAsync(string serverUrl, string password); + + Task ExportMyDataAsync(string serverUrl); + + string? GetRefreshToken(string serverUrl); +} diff --git a/src/Decho/Services/InviteService.cs b/src/Decho/Services/InviteService.cs new file mode 100644 index 0000000..fc55d5d --- /dev/null +++ b/src/Decho/Services/InviteService.cs @@ -0,0 +1,37 @@ +using EchoHub.Core.DTOs; + +namespace Decho.Services; + +internal sealed class InviteService : IInviteService +{ + private readonly IConnectionStore _store; + + public InviteService(IConnectionStore store) + { + _store = store; + } + + public async Task CreateInviteAsync(string serverUrl, int? maxUses, int? expiresInHours) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return null; + + return await entry.ApiClient.CreateInviteAsync(maxUses, expiresInHours); + } + + public async Task> GetInvitesAsync(string serverUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return []; + + return await entry.ApiClient.GetInvitesAsync(); + } + + public async Task RevokeInviteAsync(string serverUrl, string code) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.RevokeInviteAsync(code); + } +} diff --git a/src/Decho/Services/ServerConnection.cs b/src/Decho/Services/ServerConnection.cs new file mode 100644 index 0000000..650946e --- /dev/null +++ b/src/Decho/Services/ServerConnection.cs @@ -0,0 +1,21 @@ +using Decho.Models; + +using EchoHub.Client.Services; + +namespace Decho.Services; + +internal sealed class ServerConnection +{ + public ApiClient ApiClient { get; } + public ServerModel Server { get; } + public UserModel User { get; } + internal ConnectionManager Manager { get; } + + internal ServerConnection(ConnectionManager manager, ApiClient apiClient, ServerModel server, UserModel user) + { + Manager = manager; + ApiClient = apiClient; + Server = server; + User = user; + } +} diff --git a/src/Decho/Services/UserService.cs b/src/Decho/Services/UserService.cs new file mode 100644 index 0000000..6d9e891 --- /dev/null +++ b/src/Decho/Services/UserService.cs @@ -0,0 +1,137 @@ +using EchoHub.Client.Services; + +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Core.Services; + +using System.Diagnostics; + +namespace Decho.Services; + +internal sealed class UserService : IUserService +{ + private readonly IConnectionStore _store; + + public UserService(IConnectionStore store) + { + _store = store; + } + + public async Task UpdateStatusAsync(string serverUrl, UserStatus status, string? statusMessage) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.Manager.UpdateStatusAsync(status, statusMessage); + entry.User.Status = status; + entry.User.StatusMessage = statusMessage; + } + + public async Task UpdateProfileAsync(string serverUrl, string? displayName, string? bio, string? nickColor) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + _ = await entry.ApiClient.UpdateProfileAsync(new UpdateProfileRequest(displayName, bio, nickColor)); + } + + public async Task SetAvatarAsync(string serverUrl, string target) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + _ = await AvatarHelper.UploadAsync(entry.ApiClient, target); + } + + public async Task GetUserProfileAsync(string serverUrl, string username) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return null; + + return await entry.ApiClient.GetUserProfileAsync(username); + } + + public async Task> GetOnlineUsersAsync(string serverUrl, string channelName) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return []; + + return await entry.Manager.GetOnlineUsersAsync(channelName); + } + + public async Task KickUserAsync(string serverUrl, string username, string? reason) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.KickUserAsync(username, reason); + } + + public async Task BanUserAsync(string serverUrl, string username, string? reason) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.BanUserAsync(username, reason); + } + + public async Task UnbanUserAsync(string serverUrl, string username) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.UnbanUserAsync(username); + } + + public async Task MuteUserAsync(string serverUrl, string username, int? durationMinutes) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.MuteUserAsync(username, durationMinutes); + } + + public async Task UnmuteUserAsync(string serverUrl, string username) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.UnmuteUserAsync(username); + } + + public async Task AssignRoleAsync(string serverUrl, string username, ServerRole role) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) return; + + await entry.ApiClient.AssignRoleAsync(username, role); + } + + public string? GetCurrentUsername(string serverUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + return entry?.User.DisplayName; + } + + public async Task DeleteMyAccountAsync(string serverUrl, string password) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected"); + + await entry.ApiClient.DeleteMyAccountAsync(password); + } + + public async Task ExportMyDataAsync(string serverUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + if (entry is null) throw new InvalidOperationException("Not connected"); + + return await entry.ApiClient.ExportMyDataAsync(); + } + + public string? GetRefreshToken(string serverUrl) + { + ServerConnection? entry = _store.Get(serverUrl); + return entry?.ApiClient.RefreshToken; + } +} diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index bbb4133..e149f4c 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -7,10 +7,10 @@ using Decho.Views; using EchoHub.Client.Commands; using EchoHub.Client.Config; using EchoHub.Client.Services; +using EchoHub.Client.UI.Dialogs; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; -using EchoHub.Core.Security; using EchoHub.Core.Services; using MsBox.Avalonia; @@ -35,15 +35,24 @@ public sealed class MainWindowViewModel : ViewModelBase public ChatViewModel Chat { get; } - public ConnectionService ConnectionService { get; } + public IConnectionService ConnectionService { get; } + + public IChannelService ChannelService { get; } + + public IUserService UserService { get; } + + public IInviteService InviteService { get; } public ReactiveCommand AddServerCommand { get; } - public MainWindowViewModel() + public MainWindowViewModel(IConnectionService connectionService, IChannelService channelService, IUserService userService, IInviteService inviteService, CommandHandler commandHandler, NotificationSoundService notificationService) { - ConnectionService = new ConnectionService(); - _commandHandler = new CommandHandler(); - _notificationService = new NotificationSoundService(ConfigManager.Load().Notifications); + ConnectionService = connectionService; + ChannelService = channelService; + UserService = userService; + InviteService = inviteService; + _commandHandler = commandHandler; + _notificationService = notificationService; Sidebar = new SidebarViewModel(); Chat = new ChatViewModel(); @@ -87,17 +96,13 @@ public sealed class MainWindowViewModel : ViewModelBase } catch (Exception ex) { - IMsBox box = MessageBoxManager.GetMessageBoxStandard( - "Connection Failed", - $"Could not connect to server:\n{ex.Message}", - ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(_mainWindow); + await ShowErrorAsync("Connection Failed", $"Could not connect to server:\n{ex.Message}"); } } private async Task ConnectAndSaveAsync(ConnectDialogResult result) { - if (result.IsSavedSession && result.SavedRefreshToken is not null) + if (result.SavedRefreshToken is not null) { await ConnectionService.ConnectWithSavedTokenAsync( result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe); @@ -203,6 +208,12 @@ public sealed class MainWindowViewModel : ViewModelBase }); } + private async Task ShowErrorAsync(string title, string message) + { + IMsBox box = MessageBoxManager.GetMessageBoxStandard(title, message, ButtonEnum.Ok); + _ = await box.ShowWindowDialogAsync(_mainWindow); + } + private void WireCommandHandlerEvents() { _commandHandler.OnSetStatus += async (status, message) => @@ -213,12 +224,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message); - }; - - _commandHandler.OnSetTheme += themeName => - { - return Task.CompletedTask; + await UserService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message); }; _commandHandler.OnJoinChannel += async (channelName, password) => @@ -229,55 +235,21 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - ChannelCryptoDto? crypto = await ConnectionService.GetChannelCryptoAsync(serverUrl, channelName); + ChannelCryptoDto? crypto = await ChannelService.GetChannelCryptoAsync(serverUrl, channelName); bool isEncrypted = crypto is not null && crypto.IsEncrypted; - string? wirePassword = password; - - if (isEncrypted) + if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channelName) && password is null) { - ServerConnection entry = ConnectionService.Connections[serverUrl]; - entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, true); - - if (!entry.Manager.RoomKeys.HasKey(channelName) && password is null) + password = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); + if (string.IsNullOrEmpty(password)) { - password = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); - if (string.IsNullOrEmpty(password)) - { - return; - } - } - - if (password is not null) - { - byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!); - wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex; - } - } - - ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, wirePassword); - EnsureChannelInList(serverUrl, channelName); - - if (isEncrypted && !ConnectionService.Connections[serverUrl].Manager.RoomKeys.HasKey(channelName)) - { - try - { - ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync( - serverUrl, channelName, password, crypto!.EncryptionSalt!, result.WrappedRoomKey ?? ""); - if (unlockResult.History.Count > 0) - { - result = unlockResult; - } - } - catch (Exception ex) - { - IMsBox box = MessageBoxManager.GetMessageBoxStandard( - "Decrypt Error", $"Decrypt failed: {ex.Message}", ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(_mainWindow); return; } } + ChannelJoinResult result = await ChannelService.JoinWithCryptoAsync(serverUrl, channelName, password); + EnsureChannelInList(serverUrl, channelName); + ChannelModel? channelModel = FindChannel(serverUrl, channelName); if (channelModel is not null) { @@ -307,7 +279,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.LeaveChannelAsync(serverUrl, channel); + await ChannelService.LeaveChannelAsync(serverUrl, channel); }; _commandHandler.OnListUsers += async () => @@ -319,7 +291,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - List users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel); + List users = await UserService.GetOnlineUsersAsync(serverUrl, channel); string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username)); ShowSystemMessage($"Online in #{channel}: {userList}"); }; @@ -333,8 +305,8 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UpdateProfileAsync(serverUrl, null, null, null); - ConnectionService.UpdateChannelTopic(serverUrl, channel, topic); + await UserService.UpdateProfileAsync(serverUrl, null, null, null); + ChannelService.UpdateChannelTopic(serverUrl, channel, topic); Chat.ChannelTopic = topic; }; @@ -346,7 +318,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.KickUserAsync(serverUrl, username, reason); + await UserService.KickUserAsync(serverUrl, username, reason); }; _commandHandler.OnBanUser += async (username, reason) => @@ -357,7 +329,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.BanUserAsync(serverUrl, username, reason); + await UserService.BanUserAsync(serverUrl, username, reason); }; _commandHandler.OnUnbanUser += async username => @@ -368,7 +340,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UnbanUserAsync(serverUrl, username); + await UserService.UnbanUserAsync(serverUrl, username); }; _commandHandler.OnMuteUser += async (username, duration) => @@ -379,7 +351,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.MuteUserAsync(serverUrl, username, duration); + await UserService.MuteUserAsync(serverUrl, username, duration); }; _commandHandler.OnUnmuteUser += async username => @@ -390,7 +362,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UnmuteUserAsync(serverUrl, username); + await UserService.UnmuteUserAsync(serverUrl, username); }; _commandHandler.OnAssignRole += async (username, roleStr) => @@ -407,7 +379,7 @@ public sealed class MainWindowViewModel : ViewModelBase "mod" => ServerRole.Mod, _ => ServerRole.Member, }; - await ConnectionService.AssignRoleAsync(serverUrl, username, role); + await UserService.AssignRoleAsync(serverUrl, username, role); }; _commandHandler.OnNukeChannel += async () => @@ -419,7 +391,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.NukeChannelAsync(serverUrl, channel); + await ChannelService.NukeChannelAsync(serverUrl, channel); }; _commandHandler.OnTestSound += _notificationService.PlayTestAsync; @@ -434,8 +406,6 @@ public sealed class MainWindowViewModel : ViewModelBase return Task.CompletedTask; }; - _commandHandler.OnHelp += () => Task.CompletedTask; - _commandHandler.OnSendAction += async text => { await HandleSendTextAsync(MessageConventions.FormatAction(text)); @@ -455,7 +425,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - InviteDto? invite = await ConnectionService.CreateInviteAsync(serverUrl, maxUses, expiresInHours); + InviteDto? invite = await InviteService.CreateInviteAsync(serverUrl, maxUses, expiresInHours); if (invite is not null) ShowSystemMessage($"Invite code: {invite.Code}"); } @@ -472,7 +442,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - List invites = await ConnectionService.GetInvitesAsync(serverUrl); + List invites = await InviteService.GetInvitesAsync(serverUrl); if (invites.Count == 0) ShowSystemMessage("No invite codes."); else @@ -491,7 +461,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - await ConnectionService.RevokeInviteAsync(serverUrl, code); + await InviteService.RevokeInviteAsync(serverUrl, code); ShowSystemMessage($"Invite {code} revoked."); } catch (Exception ex) @@ -507,7 +477,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - string data = await ConnectionService.ExportMyDataAsync(serverUrl); + string data = await UserService.ExportMyDataAsync(serverUrl); string fileName = $"echohub-export-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json"; string downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string filePath = Path.Combine(downloadsPath, "Downloads", fileName); @@ -535,7 +505,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - await ConnectionService.DeleteMyAccountAsync(serverUrl, pwd); + await UserService.DeleteMyAccountAsync(serverUrl, pwd); ShowSystemMessage("Account deleted."); } catch (Exception ex) @@ -558,11 +528,11 @@ public sealed class MainWindowViewModel : ViewModelBase if (Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { - await ConnectionService.SendUrlAsync(serverUrl, channel, target, size); + await ChannelService.SendUrlAsync(serverUrl, channel, target, size); } else { - await ConnectionService.UploadFileAsync(serverUrl, channel, target, size); + await ChannelService.UploadFileAsync(serverUrl, channel, target, size); } } catch (Exception ex) @@ -579,7 +549,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UpdateProfileAsync(serverUrl, displayName, null, null); + await UserService.UpdateProfileAsync(serverUrl, displayName, null, null); }; _commandHandler.OnSetColor += async color => @@ -590,7 +560,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.UpdateProfileAsync(serverUrl, null, null, color); + await UserService.UpdateProfileAsync(serverUrl, null, null, color); }; _commandHandler.OnSetAvatar += async target => @@ -601,7 +571,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - await ConnectionService.SetAvatarAsync(serverUrl, target); + await UserService.SetAvatarAsync(serverUrl, target); }; _commandHandler.OnOpenProfile += async username => @@ -612,7 +582,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - string target = username ?? ConnectionService.GetCurrentUsername(serverUrl) ?? string.Empty; + string target = username ?? UserService.GetCurrentUsername(serverUrl) ?? string.Empty; if (string.IsNullOrEmpty(target)) { return; @@ -620,7 +590,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - UserProfileDto? profile = await ConnectionService.GetUserProfileAsync(serverUrl, target); + UserProfileDto? profile = await UserService.GetUserProfileAsync(serverUrl, target); Avalonia.Threading.Dispatcher.UIThread.Post(() => { if (profile is null) @@ -723,7 +693,7 @@ public sealed class MainWindowViewModel : ViewModelBase ConnectionService.MessageReceived += (serverUrl, message) => { - string? username = ConnectionService.GetCurrentUsername(serverUrl); + string? username = UserService.GetCurrentUsername(serverUrl); bool isReplyToMe = !string.IsNullOrEmpty(username) && string.Equals(message.ReplyTo?.SenderUsername, username, StringComparison.OrdinalIgnoreCase); bool isMention = isReplyToMe @@ -818,7 +788,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - await ConnectionService.SendMessageAsync(serverUrl, channelName, text); + await ChannelService.SendMessageAsync(serverUrl, channelName, text); } catch (Exception ex) { @@ -830,15 +800,15 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - List users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channelName); + List users = await UserService.GetOnlineUsersAsync(serverUrl, channelName); Avalonia.Threading.Dispatcher.UIThread.Post(() => { Chat.SetOnlineUsers(users); }); } - catch + catch (Exception ex) { - // silently ignore + Debug.WriteLine($"RefreshOnlineUsers failed: {ex.Message}"); } } @@ -865,7 +835,7 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths); + await ChannelService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths); } catch (Exception ex) { @@ -885,7 +855,7 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId); + await ChannelService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId); } catch (Exception ex) { @@ -910,7 +880,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - ChannelDto? channel = await ConnectionService.CreateChannelAsync( + ChannelDto? channel = await ChannelService.CreateChannelAsync( server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword); if (channel is null) @@ -919,7 +889,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name); + ChannelJoinResult joinResult = await ChannelService.JoinChannelAsync(server.ServerUrl, channel.Name); ChannelModel channelModel = new ChannelModel( channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected); @@ -948,9 +918,7 @@ public sealed class MainWindowViewModel : ViewModelBase } catch (Exception ex) { - IMsBox box = MessageBoxManager.GetMessageBoxStandard( - "Error", $"Could not create channel:\n{ex.Message}", ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(_mainWindow); + await ShowErrorAsync("Error", $"Could not create channel:\n{ex.Message}"); } } @@ -988,7 +956,7 @@ public sealed class MainWindowViewModel : ViewModelBase try { - await ConnectionService.DeleteChannelAsync(server.ServerUrl, channelName); + await ChannelService.DeleteChannelAsync(server.ServerUrl, channelName); Avalonia.Threading.Dispatcher.UIThread.Post(() => { @@ -1007,9 +975,7 @@ public sealed class MainWindowViewModel : ViewModelBase } catch (Exception ex) { - IMsBox box = MessageBoxManager.GetMessageBoxStandard( - "Error", $"Could not delete channel:\n{ex.Message}", ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(_mainWindow); + await ShowErrorAsync("Error", $"Could not delete channel:\n{ex.Message}"); } } @@ -1033,16 +999,16 @@ public sealed class MainWindowViewModel : ViewModelBase try { int offset = channel.Messages.Count; - List older = await ConnectionService.GetHistoryAsync(serverUrl, channelName, HubConstants.DefaultHistoryCount, offset); + List older = await ChannelService.GetHistoryAsync(serverUrl, channelName, HubConstants.DefaultHistoryCount, offset); if (older.Count > 0) { channel.InsertMessages(older); } } - catch + catch (Exception ex) { - // silently ignore + Debug.WriteLine($"LoadMore failed: {ex.Message}"); } finally { @@ -1087,59 +1053,25 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - ChannelCryptoDto? crypto = await ConnectionService.GetChannelCryptoAsync(serverUrl, channel.Name); + ChannelCryptoDto? crypto = await ChannelService.GetChannelCryptoAsync(serverUrl, channel.Name); bool isEncrypted = crypto is not null && crypto.IsEncrypted; - string? wirePassword = password; - - if (isEncrypted) + if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channel.Name) && password is null) { - ServerConnection entry = ConnectionService.Connections[serverUrl]; - entry.Manager.RoomKeys.MarkChannelEncrypted(channel.Name, true); - - if (!entry.Manager.RoomKeys.HasKey(channel.Name) && password is null) + string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); + if (string.IsNullOrEmpty(passphrase)) { - string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); - if (string.IsNullOrEmpty(passphrase)) + Avalonia.Threading.Dispatcher.UIThread.Post(() => { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - { - channel.IsLocked = true; - Chat.Composer.IsConnected = false; - }); - break; - } - password = passphrase; - } - - if (password is not null) - { - byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!); - wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex; + channel.IsLocked = true; + Chat.Composer.IsConnected = false; + }); + break; } + password = passphrase; } - ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name, wirePassword); - - if (isEncrypted && !ConnectionService.Connections[serverUrl].Manager.RoomKeys.HasKey(channel.Name)) - { - try - { - ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync( - serverUrl, channel.Name, password, crypto!.EncryptionSalt!, joinResult.WrappedRoomKey ?? ""); - if (unlockResult.History.Count > 0) - { - joinResult = unlockResult; - } - } - catch (Exception ex) - { - IMsBox errBox = MessageBoxManager.GetMessageBoxStandard( - "Decrypt Error", $"Decrypt failed: {ex.Message}", ButtonEnum.Ok); - _ = await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync( - () => errBox.ShowWindowDialogAsync(_mainWindow)); - } - } + ChannelJoinResult joinResult = await ChannelService.JoinWithCryptoAsync(serverUrl, channel.Name, password); Avalonia.Threading.Dispatcher.UIThread.Post(() => { @@ -1269,11 +1201,7 @@ public sealed class MainWindowViewModel : ViewModelBase catch (Exception ex) { serverVm.IsConnecting = false; - IMsBox box = MessageBoxManager.GetMessageBoxStandard( - "Connection Failed", - $"Could not connect to server:\n{ex.Message}", - ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(_mainWindow); + await ShowErrorAsync("Connection Failed", $"Could not connect to server:\n{ex.Message}"); } } @@ -1348,4 +1276,4 @@ public sealed class MainWindowViewModel : ViewModelBase { return Chat.CurrentServerUrl; } -} \ No newline at end of file +} diff --git a/src/Decho/Views/ConnectDialogWindow.axaml.cs b/src/Decho/Views/ConnectDialogWindow.axaml.cs index c1973c9..dde556e 100644 --- a/src/Decho/Views/ConnectDialogWindow.axaml.cs +++ b/src/Decho/Views/ConnectDialogWindow.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using EchoHub.Client.Config; +using EchoHub.Client.UI.Dialogs; using MsBox.Avalonia; using MsBox.Avalonia.Base; @@ -80,33 +81,18 @@ public sealed partial class ConnectDialogWindow : Window if (string.IsNullOrEmpty(pass) && saved is not null) { - Close(new ConnectDialogResult - { - ServerUrl = url, - Username = user, - IsSavedSession = true, - SavedRefreshToken = saved.RefreshToken, - RememberMe = saved.RememberMe, - }); + Close(new ConnectDialogResult(url, user, "", false, saved.RememberMe, saved.RefreshToken)); + return; } - else - { - if (string.IsNullOrEmpty(pass)) - { - IMsBox box = MessageBoxManager.GetMessageBoxStandard("Validation", "Password is required.", ButtonEnum.Ok); - _ = await box.ShowWindowDialogAsync(this); - return; - } - Close(new ConnectDialogResult - { - ServerUrl = url, - Username = user, - Password = pass, - IsRegister = false, - RememberMe = RememberCheck.IsChecked ?? false, - }); + if (string.IsNullOrEmpty(pass)) + { + IMsBox box = MessageBoxManager.GetMessageBoxStandard("Validation", "Password is required.", ButtonEnum.Ok); + _ = await box.ShowWindowDialogAsync(this); + return; } + + Close(new ConnectDialogResult(url, user, pass, false, RememberCheck.IsChecked ?? false, null)); } private async void OnRegisterClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -122,29 +108,11 @@ public sealed partial class ConnectDialogWindow : Window return; } - Close(new ConnectDialogResult - { - ServerUrl = url, - Username = user, - Password = pass, - IsRegister = true, - RememberMe = RememberCheck.IsChecked ?? false, - }); + Close(new ConnectDialogResult(url, user, pass, true, RememberCheck.IsChecked ?? false, null)); } private void OnCancelClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { Close(null); } -} - -public sealed class ConnectDialogResult -{ - public string ServerUrl { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public bool IsRegister { get; set; } - public bool RememberMe { get; set; } - public bool IsSavedSession { get; set; } - public string? SavedRefreshToken { get; set; } } \ No newline at end of file diff --git a/src/Decho/Views/MessageItemView.axaml.cs b/src/Decho/Views/MessageItemView.axaml.cs index 7e1a364..7dc2b25 100644 --- a/src/Decho/Views/MessageItemView.axaml.cs +++ b/src/Decho/Views/MessageItemView.axaml.cs @@ -69,8 +69,6 @@ public partial class MessageItemView : UserControl } } - private Window? GetParentWindow() => TopLevel.GetTopLevel(this) as Window; - private async Task GetOrDownloadImageAsync(MessageViewModel msg, AttachmentDto att) { if (msg.ImageCache.TryGetValue(att.Url, out Bitmap? cached)) @@ -101,13 +99,13 @@ public partial class MessageItemView : UserControl private async Task OpenProfileAsync(string username, string serverUrl) { MainWindowViewModel? mainVm = this.GetMainWindowViewModel(); - Window? parent = GetParentWindow(); + Window? parent = this.GetParentWindow(); if (mainVm is null || parent is null) { return; } - UserProfileDto? profile = await mainVm.ConnectionService.GetUserProfileAsync(serverUrl, username); + UserProfileDto? profile = await mainVm.UserService.GetUserProfileAsync(serverUrl, username); if (profile is null) { return; @@ -369,7 +367,7 @@ public partial class MessageItemView : UserControl return; } - Window? parent = GetParentWindow(); + Window? parent = this.GetParentWindow(); if (parent is null) { return; diff --git a/src/Decho/Views/OnlineUsersView.axaml.cs b/src/Decho/Views/OnlineUsersView.axaml.cs index 252d2ab..67a703f 100644 --- a/src/Decho/Views/OnlineUsersView.axaml.cs +++ b/src/Decho/Views/OnlineUsersView.axaml.cs @@ -20,18 +20,15 @@ public partial class OnlineUsersView : UserControl return; } - if (TopLevel.GetTopLevel(this) is not Window parent) - { - return; - } - - if (parent.DataContext is not MainWindowViewModel mainVm) + Window? parent = this.GetParentWindow(); + MainWindowViewModel? mainVm = this.GetMainWindowViewModel(); + if (parent is null || mainVm is null) { return; } string serverUrl = mainVm.Chat.CurrentServerUrl; - UserProfileDto? profile = await mainVm.ConnectionService.GetUserProfileAsync(serverUrl, user.Username); + UserProfileDto? profile = await mainVm.UserService.GetUserProfileAsync(serverUrl, user.Username); if (profile is null) { return; diff --git a/src/Decho/Views/ViewExtensions.cs b/src/Decho/Views/ViewExtensions.cs index 08f8120..6f9333e 100644 --- a/src/Decho/Views/ViewExtensions.cs +++ b/src/Decho/Views/ViewExtensions.cs @@ -1,3 +1,4 @@ +using Avalonia; using Avalonia.Controls; using Decho.ViewModels; @@ -15,4 +16,9 @@ internal static class ViewExtensions { return TopLevel.GetTopLevel(control)?.DataContext as MainWindowViewModel; } + + public static Window? GetParentWindow(this Control control) + { + return TopLevel.GetTopLevel(control) as Window; + } } \ No newline at end of file