mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +02:00
Refactor
This commit is contained in:
+46
-2
@@ -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>()!));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Decho.Services;
|
||||
|
||||
internal interface IConnectionStore
|
||||
{
|
||||
ServerConnection? Get(string serverUrl);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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<ButtonResult> 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<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,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<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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,4 +1276,4 @@ public sealed class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
return Chat.CurrentServerUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ButtonResult> 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<ButtonResult> 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user