mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 08:56:14 +02:00
Refactor
This commit is contained in:
+45
-1
@@ -2,9 +2,16 @@ using Avalonia;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
using Decho.Services;
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
using Decho.Views;
|
using Decho.Views;
|
||||||
|
|
||||||
|
using EchoHub.Client.Commands;
|
||||||
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
|
||||||
|
using Splat;
|
||||||
|
|
||||||
namespace Decho;
|
namespace Decho;
|
||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
@@ -20,7 +27,9 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
_viewModel = new MainWindowViewModel();
|
RegisterDependencies();
|
||||||
|
|
||||||
|
_viewModel = Locator.Current.GetService<MainWindowViewModel>()!;
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
@@ -34,4 +43,39 @@ public partial class App : Application
|
|||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
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.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Core.Security;
|
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
|
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace Decho.Services;
|
namespace Decho.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Result of joining a channel, including E2EE encryption metadata if the channel is encrypted.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ChannelJoinResult
|
public sealed class ChannelJoinResult
|
||||||
{
|
{
|
||||||
public List<MessageModel> History { get; }
|
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<ServerModel>? ServerAdded;
|
||||||
|
|
||||||
public event Action<string>? ServerRemoved;
|
public event Action<string>? ServerRemoved;
|
||||||
|
|
||||||
public event Action<ServerModel>? ServerStateChanged;
|
public event Action<ServerModel>? ServerStateChanged;
|
||||||
|
|
||||||
public event Action<string, ChannelModel>? ChannelAdded;
|
public event Action<string, ChannelModel>? ChannelAdded;
|
||||||
|
|
||||||
public event Action<string, string>? ChannelRemoved;
|
public event Action<string, string>? ChannelRemoved;
|
||||||
|
|
||||||
public event Action<string, MessageModel>? MessageReceived;
|
public event Action<string, MessageModel>? MessageReceived;
|
||||||
|
|
||||||
public event Action<string, string, string?>? UserJoined;
|
public event Action<string, string, string?>? UserJoined;
|
||||||
|
|
||||||
public event Action<string, string>? UserLeft;
|
public event Action<string, string>? UserLeft;
|
||||||
|
|
||||||
public event Action<string, string>? ErrorOccurred;
|
public event Action<string, string>? ErrorOccurred;
|
||||||
|
|
||||||
public event Action<string, string>? ChannelDeleted;
|
public event Action<string, string>? ChannelDeleted;
|
||||||
|
|
||||||
private readonly Dictionary<string, ServerConnection> _connections = new(StringComparer.OrdinalIgnoreCase);
|
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 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)
|
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);
|
ServerRemoved?.Invoke(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageAsync(string serverUrl, string channelName, string content, Guid? replyToMessageId = null)
|
// ── Non-delegated helpers ─────────────────────────────────────────────
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string? GetRefreshToken(string serverUrl)
|
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)
|
public async Task<string?> DownloadAttachmentAsync(string serverUrl, string channelName, string relativeUrl, string fileName)
|
||||||
{
|
=> await _attachment.DownloadAttachmentAsync(serverUrl, channelName, relativeUrl, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl)
|
public async Task<byte[]?> DownloadImageBytesAsync(string serverUrl, string channelName, string relativeUrl)
|
||||||
{
|
=> await _attachment.DownloadImageBytesAsync(serverUrl, channelName, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -703,6 +121,8 @@ public sealed class ConnectionService : IDisposable
|
|||||||
_connections.Clear();
|
_connections.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
internal static ChannelModel ChannelModelFromDto(ChannelDto dto)
|
internal static ChannelModel ChannelModelFromDto(ChannelDto dto)
|
||||||
{
|
{
|
||||||
return new ChannelModel(
|
return new ChannelModel(
|
||||||
@@ -739,35 +159,14 @@ public sealed class ConnectionService : IDisposable
|
|||||||
return _connections.TryGetValue(serverUrl, out ServerConnection? conn) ? conn : null;
|
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();
|
_config.RemoveServerFromConfig(serverUrl);
|
||||||
SavedServer? saved = config.SavedServers.FirstOrDefault(s =>
|
|
||||||
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
|
|
||||||
action(config, saved);
|
|
||||||
ConfigManager.Save(config);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RemoveServerFromConfig(string serverUrl)
|
private void RemoveFromLeftChannels(string serverUrl, string channelName)
|
||||||
{
|
{
|
||||||
ModifyConfig(serverUrl, (config, saved) =>
|
_config.RemoveFromLeftChannels(serverUrl, channelName);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ServerModel> ConnectCoreAsync(ConnectDialogResult dialogResult)
|
private async Task<ServerModel> ConnectCoreAsync(ConnectDialogResult dialogResult)
|
||||||
@@ -837,15 +236,15 @@ public sealed class ConnectionService : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_ = await JoinChannelAsync(serverUrl, ch.Name);
|
_ = await _channelService.JoinChannelAsync(serverUrl, ch.Name);
|
||||||
|
RemoveFromLeftChannels(serverUrl, ch.Name);
|
||||||
}
|
}
|
||||||
catch (EchoHub.Client.Services.ChannelPasswordRequiredException)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ModifyConfig(serverUrl, (config, saved) =>
|
_config.SaveRefreshToken(serverUrl, token, entry.User.Id);
|
||||||
{
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WireConnectionEvents(ServerConnection entry, ConnectionManager conn)
|
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.Commands;
|
||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
using EchoHub.Client.Services;
|
using EchoHub.Client.Services;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Core.Security;
|
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
|
|
||||||
using MsBox.Avalonia;
|
using MsBox.Avalonia;
|
||||||
@@ -35,15 +35,24 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ChatViewModel Chat { get; }
|
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 ReactiveCommand<Unit, Unit> AddServerCommand { get; }
|
||||||
|
|
||||||
public MainWindowViewModel()
|
public MainWindowViewModel(IConnectionService connectionService, IChannelService channelService, IUserService userService, IInviteService inviteService, CommandHandler commandHandler, NotificationSoundService notificationService)
|
||||||
{
|
{
|
||||||
ConnectionService = new ConnectionService();
|
ConnectionService = connectionService;
|
||||||
_commandHandler = new CommandHandler();
|
ChannelService = channelService;
|
||||||
_notificationService = new NotificationSoundService(ConfigManager.Load().Notifications);
|
UserService = userService;
|
||||||
|
InviteService = inviteService;
|
||||||
|
_commandHandler = commandHandler;
|
||||||
|
_notificationService = notificationService;
|
||||||
|
|
||||||
Sidebar = new SidebarViewModel();
|
Sidebar = new SidebarViewModel();
|
||||||
Chat = new ChatViewModel();
|
Chat = new ChatViewModel();
|
||||||
@@ -87,17 +96,13 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
|
await ShowErrorAsync("Connection Failed", $"Could not connect to server:\n{ex.Message}");
|
||||||
"Connection Failed",
|
|
||||||
$"Could not connect to server:\n{ex.Message}",
|
|
||||||
ButtonEnum.Ok);
|
|
||||||
_ = await box.ShowWindowDialogAsync(_mainWindow);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ConnectAndSaveAsync(ConnectDialogResult result)
|
private async Task ConnectAndSaveAsync(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
if (result.IsSavedSession && result.SavedRefreshToken is not null)
|
if (result.SavedRefreshToken is not null)
|
||||||
{
|
{
|
||||||
await ConnectionService.ConnectWithSavedTokenAsync(
|
await ConnectionService.ConnectWithSavedTokenAsync(
|
||||||
result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe);
|
result.ServerUrl, result.Username, result.SavedRefreshToken, result.RememberMe);
|
||||||
@@ -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()
|
private void WireCommandHandlerEvents()
|
||||||
{
|
{
|
||||||
_commandHandler.OnSetStatus += async (status, message) =>
|
_commandHandler.OnSetStatus += async (status, message) =>
|
||||||
@@ -213,12 +224,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message);
|
await UserService.UpdateStatusAsync(serverUrl, status ?? UserStatus.Online, message);
|
||||||
};
|
|
||||||
|
|
||||||
_commandHandler.OnSetTheme += themeName =>
|
|
||||||
{
|
|
||||||
return Task.CompletedTask;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnJoinChannel += async (channelName, password) =>
|
_commandHandler.OnJoinChannel += async (channelName, password) =>
|
||||||
@@ -229,17 +235,10 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChannelCryptoDto? crypto = await ConnectionService.GetChannelCryptoAsync(serverUrl, channelName);
|
ChannelCryptoDto? crypto = await ChannelService.GetChannelCryptoAsync(serverUrl, channelName);
|
||||||
bool isEncrypted = crypto is not null && crypto.IsEncrypted;
|
bool isEncrypted = crypto is not null && crypto.IsEncrypted;
|
||||||
|
|
||||||
string? wirePassword = password;
|
if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channelName) && password is null)
|
||||||
|
|
||||||
if (isEncrypted)
|
|
||||||
{
|
|
||||||
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");
|
password = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock");
|
||||||
if (string.IsNullOrEmpty(password))
|
if (string.IsNullOrEmpty(password))
|
||||||
@@ -248,36 +247,9 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password is not null)
|
ChannelJoinResult result = await ChannelService.JoinWithCryptoAsync(serverUrl, channelName, password);
|
||||||
{
|
|
||||||
byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!);
|
|
||||||
wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, wirePassword);
|
|
||||||
EnsureChannelInList(serverUrl, channelName);
|
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);
|
ChannelModel? channelModel = FindChannel(serverUrl, channelName);
|
||||||
if (channelModel is not null)
|
if (channelModel is not null)
|
||||||
{
|
{
|
||||||
@@ -307,7 +279,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.LeaveChannelAsync(serverUrl, channel);
|
await ChannelService.LeaveChannelAsync(serverUrl, channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnListUsers += async () =>
|
_commandHandler.OnListUsers += async () =>
|
||||||
@@ -319,7 +291,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
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));
|
string userList = string.Join(", ", users.Select(u => u.DisplayName ?? u.Username));
|
||||||
ShowSystemMessage($"Online in #{channel}: {userList}");
|
ShowSystemMessage($"Online in #{channel}: {userList}");
|
||||||
};
|
};
|
||||||
@@ -333,8 +305,8 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UpdateProfileAsync(serverUrl, null, null, null);
|
await UserService.UpdateProfileAsync(serverUrl, null, null, null);
|
||||||
ConnectionService.UpdateChannelTopic(serverUrl, channel, topic);
|
ChannelService.UpdateChannelTopic(serverUrl, channel, topic);
|
||||||
Chat.ChannelTopic = topic;
|
Chat.ChannelTopic = topic;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,7 +318,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.KickUserAsync(serverUrl, username, reason);
|
await UserService.KickUserAsync(serverUrl, username, reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnBanUser += async (username, reason) =>
|
_commandHandler.OnBanUser += async (username, reason) =>
|
||||||
@@ -357,7 +329,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.BanUserAsync(serverUrl, username, reason);
|
await UserService.BanUserAsync(serverUrl, username, reason);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnUnbanUser += async username =>
|
_commandHandler.OnUnbanUser += async username =>
|
||||||
@@ -368,7 +340,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UnbanUserAsync(serverUrl, username);
|
await UserService.UnbanUserAsync(serverUrl, username);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnMuteUser += async (username, duration) =>
|
_commandHandler.OnMuteUser += async (username, duration) =>
|
||||||
@@ -379,7 +351,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.MuteUserAsync(serverUrl, username, duration);
|
await UserService.MuteUserAsync(serverUrl, username, duration);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnUnmuteUser += async username =>
|
_commandHandler.OnUnmuteUser += async username =>
|
||||||
@@ -390,7 +362,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UnmuteUserAsync(serverUrl, username);
|
await UserService.UnmuteUserAsync(serverUrl, username);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
||||||
@@ -407,7 +379,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
"mod" => ServerRole.Mod,
|
"mod" => ServerRole.Mod,
|
||||||
_ => ServerRole.Member,
|
_ => ServerRole.Member,
|
||||||
};
|
};
|
||||||
await ConnectionService.AssignRoleAsync(serverUrl, username, role);
|
await UserService.AssignRoleAsync(serverUrl, username, role);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnNukeChannel += async () =>
|
_commandHandler.OnNukeChannel += async () =>
|
||||||
@@ -419,7 +391,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.NukeChannelAsync(serverUrl, channel);
|
await ChannelService.NukeChannelAsync(serverUrl, channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnTestSound += _notificationService.PlayTestAsync;
|
_commandHandler.OnTestSound += _notificationService.PlayTestAsync;
|
||||||
@@ -434,8 +406,6 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnHelp += () => Task.CompletedTask;
|
|
||||||
|
|
||||||
_commandHandler.OnSendAction += async text =>
|
_commandHandler.OnSendAction += async text =>
|
||||||
{
|
{
|
||||||
await HandleSendTextAsync(MessageConventions.FormatAction(text));
|
await HandleSendTextAsync(MessageConventions.FormatAction(text));
|
||||||
@@ -455,7 +425,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
InviteDto? invite = await ConnectionService.CreateInviteAsync(serverUrl, maxUses, expiresInHours);
|
InviteDto? invite = await InviteService.CreateInviteAsync(serverUrl, maxUses, expiresInHours);
|
||||||
if (invite is not null)
|
if (invite is not null)
|
||||||
ShowSystemMessage($"Invite code: {invite.Code}");
|
ShowSystemMessage($"Invite code: {invite.Code}");
|
||||||
}
|
}
|
||||||
@@ -472,7 +442,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
List<InviteDto> invites = await ConnectionService.GetInvitesAsync(serverUrl);
|
List<InviteDto> invites = await InviteService.GetInvitesAsync(serverUrl);
|
||||||
if (invites.Count == 0)
|
if (invites.Count == 0)
|
||||||
ShowSystemMessage("No invite codes.");
|
ShowSystemMessage("No invite codes.");
|
||||||
else
|
else
|
||||||
@@ -491,7 +461,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.RevokeInviteAsync(serverUrl, code);
|
await InviteService.RevokeInviteAsync(serverUrl, code);
|
||||||
ShowSystemMessage($"Invite {code} revoked.");
|
ShowSystemMessage($"Invite {code} revoked.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -507,7 +477,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string data = await ConnectionService.ExportMyDataAsync(serverUrl);
|
string data = await UserService.ExportMyDataAsync(serverUrl);
|
||||||
string fileName = $"echohub-export-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json";
|
string fileName = $"echohub-export-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json";
|
||||||
string downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
string downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
string filePath = Path.Combine(downloadsPath, "Downloads", fileName);
|
string filePath = Path.Combine(downloadsPath, "Downloads", fileName);
|
||||||
@@ -535,7 +505,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.DeleteMyAccountAsync(serverUrl, pwd);
|
await UserService.DeleteMyAccountAsync(serverUrl, pwd);
|
||||||
ShowSystemMessage("Account deleted.");
|
ShowSystemMessage("Account deleted.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -558,11 +528,11 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
if (Uri.TryCreate(target, UriKind.Absolute, out Uri? uri)
|
if (Uri.TryCreate(target, UriKind.Absolute, out Uri? uri)
|
||||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
{
|
{
|
||||||
await ConnectionService.SendUrlAsync(serverUrl, channel, target, size);
|
await ChannelService.SendUrlAsync(serverUrl, channel, target, size);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await ConnectionService.UploadFileAsync(serverUrl, channel, target, size);
|
await ChannelService.UploadFileAsync(serverUrl, channel, target, size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -579,7 +549,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UpdateProfileAsync(serverUrl, displayName, null, null);
|
await UserService.UpdateProfileAsync(serverUrl, displayName, null, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnSetColor += async color =>
|
_commandHandler.OnSetColor += async color =>
|
||||||
@@ -590,7 +560,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.UpdateProfileAsync(serverUrl, null, null, color);
|
await UserService.UpdateProfileAsync(serverUrl, null, null, color);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnSetAvatar += async target =>
|
_commandHandler.OnSetAvatar += async target =>
|
||||||
@@ -601,7 +571,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ConnectionService.SetAvatarAsync(serverUrl, target);
|
await UserService.SetAvatarAsync(serverUrl, target);
|
||||||
};
|
};
|
||||||
|
|
||||||
_commandHandler.OnOpenProfile += async username =>
|
_commandHandler.OnOpenProfile += async username =>
|
||||||
@@ -612,7 +582,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string target = username ?? ConnectionService.GetCurrentUsername(serverUrl) ?? string.Empty;
|
string target = username ?? UserService.GetCurrentUsername(serverUrl) ?? string.Empty;
|
||||||
if (string.IsNullOrEmpty(target))
|
if (string.IsNullOrEmpty(target))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -620,7 +590,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
UserProfileDto? profile = await ConnectionService.GetUserProfileAsync(serverUrl, target);
|
UserProfileDto? profile = await UserService.GetUserProfileAsync(serverUrl, target);
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
if (profile is null)
|
if (profile is null)
|
||||||
@@ -723,7 +693,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
ConnectionService.MessageReceived += (serverUrl, message) =>
|
ConnectionService.MessageReceived += (serverUrl, message) =>
|
||||||
{
|
{
|
||||||
string? username = ConnectionService.GetCurrentUsername(serverUrl);
|
string? username = UserService.GetCurrentUsername(serverUrl);
|
||||||
bool isReplyToMe = !string.IsNullOrEmpty(username)
|
bool isReplyToMe = !string.IsNullOrEmpty(username)
|
||||||
&& string.Equals(message.ReplyTo?.SenderUsername, username, StringComparison.OrdinalIgnoreCase);
|
&& string.Equals(message.ReplyTo?.SenderUsername, username, StringComparison.OrdinalIgnoreCase);
|
||||||
bool isMention = isReplyToMe
|
bool isMention = isReplyToMe
|
||||||
@@ -818,7 +788,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.SendMessageAsync(serverUrl, channelName, text);
|
await ChannelService.SendMessageAsync(serverUrl, channelName, text);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -830,15 +800,15 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
List<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channelName);
|
List<UserPresenceDto> users = await UserService.GetOnlineUsersAsync(serverUrl, channelName);
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
Chat.SetOnlineUsers(users);
|
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
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths);
|
await ChannelService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -885,7 +855,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId);
|
await ChannelService.SendMessageAsync(serverUrl, Chat.CurrentChannelName, text, replyToMessageId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -910,7 +880,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ChannelDto? channel = await ConnectionService.CreateChannelAsync(
|
ChannelDto? channel = await ChannelService.CreateChannelAsync(
|
||||||
server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword);
|
server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword);
|
||||||
|
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
@@ -919,7 +889,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name);
|
ChannelJoinResult joinResult = await ChannelService.JoinChannelAsync(server.ServerUrl, channel.Name);
|
||||||
|
|
||||||
ChannelModel channelModel = new ChannelModel(
|
ChannelModel channelModel = new ChannelModel(
|
||||||
channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected);
|
channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected);
|
||||||
@@ -948,9 +918,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
|
await ShowErrorAsync("Error", $"Could not create channel:\n{ex.Message}");
|
||||||
"Error", $"Could not create channel:\n{ex.Message}", ButtonEnum.Ok);
|
|
||||||
_ = await box.ShowWindowDialogAsync(_mainWindow);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +956,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectionService.DeleteChannelAsync(server.ServerUrl, channelName);
|
await ChannelService.DeleteChannelAsync(server.ServerUrl, channelName);
|
||||||
|
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -1007,9 +975,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
|
await ShowErrorAsync("Error", $"Could not delete channel:\n{ex.Message}");
|
||||||
"Error", $"Could not delete channel:\n{ex.Message}", ButtonEnum.Ok);
|
|
||||||
_ = await box.ShowWindowDialogAsync(_mainWindow);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,16 +999,16 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
int offset = channel.Messages.Count;
|
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)
|
if (older.Count > 0)
|
||||||
{
|
{
|
||||||
channel.InsertMessages(older);
|
channel.InsertMessages(older);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// silently ignore
|
Debug.WriteLine($"LoadMore failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -1087,17 +1053,10 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
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;
|
bool isEncrypted = crypto is not null && crypto.IsEncrypted;
|
||||||
|
|
||||||
string? wirePassword = password;
|
if (isEncrypted && !ChannelService.HasChannelKey(serverUrl, channel.Name) && password is null)
|
||||||
|
|
||||||
if (isEncrypted)
|
|
||||||
{
|
|
||||||
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");
|
string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock");
|
||||||
if (string.IsNullOrEmpty(passphrase))
|
if (string.IsNullOrEmpty(passphrase))
|
||||||
@@ -1112,34 +1071,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
password = passphrase;
|
password = passphrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password is not null)
|
ChannelJoinResult joinResult = await ChannelService.JoinWithCryptoAsync(serverUrl, channel.Name, password);
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -1269,11 +1201,7 @@ public sealed class MainWindowViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
serverVm.IsConnecting = false;
|
serverVm.IsConnecting = false;
|
||||||
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
|
await ShowErrorAsync("Connection Failed", $"Could not connect to server:\n{ex.Message}");
|
||||||
"Connection Failed",
|
|
||||||
$"Could not connect to server:\n{ex.Message}",
|
|
||||||
ButtonEnum.Ok);
|
|
||||||
_ = await box.ShowWindowDialogAsync(_mainWindow);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
using MsBox.Avalonia;
|
using MsBox.Avalonia;
|
||||||
using MsBox.Avalonia.Base;
|
using MsBox.Avalonia.Base;
|
||||||
@@ -80,17 +81,10 @@ public sealed partial class ConnectDialogWindow : Window
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(pass) && saved is not null)
|
if (string.IsNullOrEmpty(pass) && saved is not null)
|
||||||
{
|
{
|
||||||
Close(new ConnectDialogResult
|
Close(new ConnectDialogResult(url, user, "", false, saved.RememberMe, saved.RefreshToken));
|
||||||
{
|
return;
|
||||||
ServerUrl = url,
|
|
||||||
Username = user,
|
|
||||||
IsSavedSession = true,
|
|
||||||
SavedRefreshToken = saved.RefreshToken,
|
|
||||||
RememberMe = saved.RememberMe,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(pass))
|
if (string.IsNullOrEmpty(pass))
|
||||||
{
|
{
|
||||||
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard("Validation", "Password is required.", ButtonEnum.Ok);
|
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard("Validation", "Password is required.", ButtonEnum.Ok);
|
||||||
@@ -98,15 +92,7 @@ public sealed partial class ConnectDialogWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Close(new ConnectDialogResult
|
Close(new ConnectDialogResult(url, user, pass, false, RememberCheck.IsChecked ?? false, null));
|
||||||
{
|
|
||||||
ServerUrl = url,
|
|
||||||
Username = user,
|
|
||||||
Password = pass,
|
|
||||||
IsRegister = false,
|
|
||||||
RememberMe = RememberCheck.IsChecked ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnRegisterClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private async void OnRegisterClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
@@ -122,14 +108,7 @@ public sealed partial class ConnectDialogWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Close(new ConnectDialogResult
|
Close(new ConnectDialogResult(url, user, pass, true, RememberCheck.IsChecked ?? false, null));
|
||||||
{
|
|
||||||
ServerUrl = url,
|
|
||||||
Username = user,
|
|
||||||
Password = pass,
|
|
||||||
IsRegister = true,
|
|
||||||
RememberMe = RememberCheck.IsChecked ?? false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCancelClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void OnCancelClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
@@ -137,14 +116,3 @@ public sealed partial class ConnectDialogWindow : Window
|
|||||||
Close(null);
|
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)
|
private async Task<Bitmap?> GetOrDownloadImageAsync(MessageViewModel msg, AttachmentDto att)
|
||||||
{
|
{
|
||||||
if (msg.ImageCache.TryGetValue(att.Url, out Bitmap? cached))
|
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)
|
private async Task OpenProfileAsync(string username, string serverUrl)
|
||||||
{
|
{
|
||||||
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
|
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
|
||||||
Window? parent = GetParentWindow();
|
Window? parent = this.GetParentWindow();
|
||||||
if (mainVm is null || parent is null)
|
if (mainVm is null || parent is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserProfileDto? profile = await mainVm.ConnectionService.GetUserProfileAsync(serverUrl, username);
|
UserProfileDto? profile = await mainVm.UserService.GetUserProfileAsync(serverUrl, username);
|
||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -369,7 +367,7 @@ public partial class MessageItemView : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Window? parent = GetParentWindow();
|
Window? parent = this.GetParentWindow();
|
||||||
if (parent is null)
|
if (parent is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,18 +20,15 @@ public partial class OnlineUsersView : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TopLevel.GetTopLevel(this) is not Window parent)
|
Window? parent = this.GetParentWindow();
|
||||||
{
|
MainWindowViewModel? mainVm = this.GetMainWindowViewModel();
|
||||||
return;
|
if (parent is null || mainVm is null)
|
||||||
}
|
|
||||||
|
|
||||||
if (parent.DataContext is not MainWindowViewModel mainVm)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
string serverUrl = mainVm.Chat.CurrentServerUrl;
|
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)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
||||||
using Decho.ViewModels;
|
using Decho.ViewModels;
|
||||||
@@ -15,4 +16,9 @@ internal static class ViewExtensions
|
|||||||
{
|
{
|
||||||
return TopLevel.GetTopLevel(control)?.DataContext as MainWindowViewModel;
|
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