This commit is contained in:
Stone_Red
2026-07-18 16:52:49 +02:00
parent 0c300e7509
commit 59f6f1d1bd
23 changed files with 1111 additions and 883 deletions
+45 -1
View File
@@ -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<MainWindowViewModel>()!;
desktop.MainWindow = new MainWindow
{
@@ -34,4 +43,39 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private static void RegisterDependencies()
{
ConnectionService connectionService = new();
Locator.CurrentMutable.RegisterLazySingleton<IConnectionService>(() => connectionService);
IConnectionStore store = connectionService.Store;
Locator.CurrentMutable.RegisterLazySingleton<IConnectionStore>(() => store);
ICryptoService crypto = new CryptoService(store);
Locator.CurrentMutable.RegisterLazySingleton<ICryptoService>(() => crypto);
IChannelService channelService = new ChannelService(store, crypto);
Locator.CurrentMutable.RegisterLazySingleton<IChannelService>(() => channelService);
IUserService userService = new UserService(store);
Locator.CurrentMutable.RegisterLazySingleton<IUserService>(() => userService);
IInviteService inviteService = new InviteService(store);
Locator.CurrentMutable.RegisterLazySingleton<IInviteService>(() => inviteService);
CommandHandler commandHandler = new CommandHandler();
Locator.CurrentMutable.RegisterLazySingleton<CommandHandler>(() => commandHandler);
NotificationSoundService notificationService = new(ConfigManager.Load().Notifications);
Locator.CurrentMutable.RegisterLazySingleton<NotificationSoundService>(() => notificationService);
Locator.CurrentMutable.Register<MainWindowViewModel>(() =>
new MainWindowViewModel(
Locator.Current.GetService<IConnectionService>()!,
Locator.Current.GetService<IChannelService>()!,
Locator.Current.GetService<IUserService>()!,
Locator.Current.GetService<IInviteService>()!,
Locator.Current.GetService<CommandHandler>()!,
Locator.Current.GetService<NotificationSoundService>()!));
}
}
+100
View File
@@ -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<string?> 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<byte[]?> 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;
}
}
}
+344
View File
@@ -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<ChannelJoinResult> 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<ChannelDto?> 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<ChannelJoinResult> 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<MessageModel> 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<MessageModel> history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList();
return new ChannelJoinResult(history, true, outcome.EncryptionSalt, outcome.WrappedRoomKey);
}
List<MessageDto> existing = await entry.Manager.GetHistoryAsync(channelName);
List<MessageModel> 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<ChannelCryptoDto?> 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<ChannelJoinResult> 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<MessageDto> 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<List<ChannelDto>> 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<string> 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<OutgoingAttachment> attachments = new List<OutgoingAttachment>(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<List<MessageModel>> GetHistoryAsync(string serverUrl, string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{
ServerConnection? entry = _store.Get(serverUrl);
if (entry is null) return [];
List<MessageDto> messages = await entry.Manager.GetHistoryAsync(channelName, count, offset);
return messages.Select(m => MessageModelFromDto(m, entry)).ToList();
}
private static async Task<OutgoingAttachment> 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<AttachmentDto> attachments = dto.Attachments ?? [];
return new MessageModel(
dto.Id.ToString("N"),
author,
dto.SentAt,
dto.Content,
dto.ChannelName,
entry.Server.ServerUrl,
attachments,
dto.ReplyTo);
}
}
@@ -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<ClientConfig, SavedServer?> action)
{
ClientConfig config = ConfigManager.Load();
SavedServer? saved = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
action(config, saved);
ConfigManager.Save(config);
}
}
+38 -672
View File
@@ -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;
/// <summary>
/// Result of joining a channel, including E2EE encryption metadata if the channel is encrypted.
/// </summary>
public sealed class ChannelJoinResult
{
public List<MessageModel> History { get; }
@@ -32,30 +29,43 @@ public sealed class ChannelJoinResult
}
}
public sealed class ConnectionService : IDisposable
public sealed class ConnectionService : IConnectionService
{
public event Action<ServerModel>? ServerAdded;
public event Action<string>? ServerRemoved;
public event Action<ServerModel>? ServerStateChanged;
public event Action<string, ChannelModel>? ChannelAdded;
public event Action<string, string>? ChannelRemoved;
public event Action<string, MessageModel>? MessageReceived;
public event Action<string, string, string?>? UserJoined;
public event Action<string, string>? UserLeft;
public event Action<string, string>? ErrorOccurred;
public event Action<string, string>? ChannelDeleted;
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
private readonly IConfigPersistenceService _config;
private readonly IAttachmentService _attachment;
private readonly ConnectionStore _store;
private readonly IChannelService _channelService;
internal IReadOnlyDictionary<string, ServerConnection> 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<ServerModel> 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<string> 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<OutgoingAttachment> attachments = new List<OutgoingAttachment>(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<ChannelDto?> 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<ChannelJoinResult> 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<MessageModel> 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<MessageModel> history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList();
return new ChannelJoinResult(history, true, outcome.EncryptionSalt, outcome.WrappedRoomKey);
}
List<MessageDto> existing = await entry.Manager.GetHistoryAsync(channelName);
List<MessageModel> hist = existing.Select(m => MessageModelFromDto(m, entry)).ToList();
return new ChannelJoinResult(hist, encFlag && !hasKeyFlag, null, null);
}
public async Task<ChannelJoinResult> 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<MessageDto> 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<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<List<ChannelDto>> GetChannelsAsync(string serverUrl)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
return [];
}
return await entry.ApiClient.GetChannelsAsync();
}
public async Task<ChannelCryptoDto?> 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<InviteDto?> 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<List<InviteDto>> 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<string> 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<UserProfileDto?> 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<string?> 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<byte[]?> 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<ClientConfig, SavedServer?> 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<ServerModel> 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;
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Decho.Services;
internal sealed class ConnectionStore : IConnectionStore
{
private readonly Dictionary<string, ServerConnection> _connections;
public ConnectionStore(Dictionary<string, ServerConnection> connections)
{
_connections = connections;
}
public ServerConnection? Get(string serverUrl)
=> _connections.TryGetValue(serverUrl, out ServerConnection? entry) ? entry : null;
}
+40
View File
@@ -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);
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Decho.Services;
public interface IAttachmentService
{
Task<string?> DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName);
Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl);
}
+48
View File
@@ -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<ChannelJoinResult> JoinWithCryptoAsync(string serverUrl, string channelName, string? password);
Task<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null);
Task<ChannelJoinResult> 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<ChannelCryptoDto?> GetChannelCryptoAsync(string serverUrl, string channelName);
Task<ChannelJoinResult> 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<List<ChannelDto>> GetChannelsAsync(string serverUrl);
Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null);
Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList<string> 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<List<MessageModel>> GetHistoryAsync(string serverUrl, string channelName, int count = 50, int offset = 0);
}
@@ -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);
}
+30
View File
@@ -0,0 +1,30 @@
using Decho.Models;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
namespace Decho.Services;
public interface IConnectionService : IDisposable
{
event Action<ServerModel>? ServerAdded;
event Action<string>? ServerRemoved;
event Action<ServerModel>? ServerStateChanged;
event Action<string, ChannelModel>? ChannelAdded;
event Action<string, string>? ChannelRemoved;
event Action<string, MessageModel>? MessageReceived;
event Action<string, string, string?>? UserJoined;
event Action<string, string>? UserLeft;
event Action<string, string>? ErrorOccurred;
event Action<string, string>? ChannelDeleted;
Task<ServerModel> 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<string?> DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName);
Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl);
}
+6
View File
@@ -0,0 +1,6 @@
namespace Decho.Services;
internal interface IConnectionStore
{
ServerConnection? Get(string serverUrl);
}
+8
View File
@@ -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);
}
+12
View File
@@ -0,0 +1,12 @@
using EchoHub.Core.DTOs;
namespace Decho.Services;
public interface IInviteService
{
Task<InviteDto?> CreateInviteAsync(string serverUrl, int? maxUses, int? expiresInHours);
Task<List<InviteDto>> GetInvitesAsync(string serverUrl);
Task RevokeInviteAsync(string serverUrl, string code);
}
+37
View File
@@ -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<UserProfileDto?> GetUserProfileAsync(string serverUrl, string username);
Task<List<UserPresenceDto>> 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<string> ExportMyDataAsync(string serverUrl);
string? GetRefreshToken(string serverUrl);
}
+37
View File
@@ -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<InviteDto?> 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<List<InviteDto>> 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);
}
}
+21
View File
@@ -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;
}
}
+137
View File
@@ -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<UserProfileDto?> 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<List<UserPresenceDto>> 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<string> 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;
}
}
+69 -141
View File
@@ -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<Unit, Unit> 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<ButtonResult> 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<ButtonResult> 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,17 +235,10 @@ 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)
{
ServerConnection entry = ConnectionService.Connections[serverUrl];
entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, true);
if (!entry.Manager.RoomKeys.HasKey(channelName) && password is null)
if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channelName) && password is null)
{
password = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock");
if (string.IsNullOrEmpty(password))
@@ -248,36 +247,9 @@ public sealed class MainWindowViewModel : ViewModelBase
}
}
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);
ChannelJoinResult result = await ChannelService.JoinWithCryptoAsync(serverUrl, channelName, password);
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<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
"Decrypt Error", $"Decrypt failed: {ex.Message}", ButtonEnum.Ok);
_ = await box.ShowWindowDialogAsync(_mainWindow);
return;
}
}
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<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channel);
List<UserPresenceDto> 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<InviteDto> invites = await ConnectionService.GetInvitesAsync(serverUrl);
List<InviteDto> 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<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channelName);
List<UserPresenceDto> 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<ButtonResult> 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<ButtonResult> 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<MessageModel> older = await ConnectionService.GetHistoryAsync(serverUrl, channelName, HubConstants.DefaultHistoryCount, offset);
List<MessageModel> 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,17 +1053,10 @@ 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)
{
ServerConnection entry = ConnectionService.Connections[serverUrl];
entry.Manager.RoomKeys.MarkChannelEncrypted(channel.Name, true);
if (!entry.Manager.RoomKeys.HasKey(channel.Name) && password is null)
if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channel.Name) && password is null)
{
string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock");
if (string.IsNullOrEmpty(passphrase))
@@ -1112,34 +1071,7 @@ public sealed class MainWindowViewModel : ViewModelBase
password = passphrase;
}
if (password is not null)
{
byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!);
wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex;
}
}
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<ButtonResult> 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<ButtonResult> 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}");
}
}
+6 -38
View File
@@ -1,6 +1,7 @@
using Avalonia.Controls;
using EchoHub.Client.Config;
using EchoHub.Client.UI.Dialogs;
using MsBox.Avalonia;
using MsBox.Avalonia.Base;
@@ -80,17 +81,10 @@ 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<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard("Validation", "Password is required.", ButtonEnum.Ok);
@@ -98,15 +92,7 @@ public sealed partial class ConnectDialogWindow : Window
return;
}
Close(new ConnectDialogResult
{
ServerUrl = url,
Username = user,
Password = pass,
IsRegister = false,
RememberMe = RememberCheck.IsChecked ?? false,
});
}
Close(new ConnectDialogResult(url, user, pass, false, RememberCheck.IsChecked ?? false, null));
}
private async void OnRegisterClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -122,14 +108,7 @@ 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)
@@ -137,14 +116,3 @@ public sealed partial class ConnectDialogWindow : Window
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; }
}
+3 -5
View File
@@ -69,8 +69,6 @@ public partial class MessageItemView : UserControl
}
}
private Window? GetParentWindow() => TopLevel.GetTopLevel(this) as Window;
private async Task<Bitmap?> 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;
+4 -7
View File
@@ -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;
+6
View File
@@ -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;
}
}