mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
- Fix userlist not refreshing after creating a new channel. - Implement unmute timer with a background job to automatically unmute users. - Improve userlist display by filtering invisible users and ensuring proper transitions between statuses. - Add clickable usernames, @mentions, and #channels for easier navigation. - Embed theme colors from source sites for a more cohesive UI. - Introduce a stateful userlist that updates incrementally via SignalR events. - Restrict auto-opening of files to safe types only, enhancing security. - Refactor user management into a dedicated service to reduce code duplication. - Add a MuteExpirationService to handle timed mutes. - Update documentation with Mermaid diagrams for major flows.
1118 lines
39 KiB
C#
1118 lines
39 KiB
C#
using EchoHub.Client.Commands;
|
|
using EchoHub.Client.Config;
|
|
using EchoHub.Client.Services;
|
|
using EchoHub.Client.Themes;
|
|
using EchoHub.Client.UI;
|
|
using EchoHub.Client.UI.Chat;
|
|
using EchoHub.Client.UI.Dialogs;
|
|
using EchoHub.Core.Constants;
|
|
using EchoHub.Core.DTOs;
|
|
using EchoHub.Core.Models;
|
|
using Serilog;
|
|
using Terminal.Gui.App;
|
|
using Terminal.Gui.Views;
|
|
|
|
namespace EchoHub.Client;
|
|
|
|
/// <summary>
|
|
/// Central orchestrator for the EchoHub TUI client.
|
|
/// Wires UI events to service calls and connection events to UI updates.
|
|
/// </summary>
|
|
public sealed class AppOrchestrator : IDisposable
|
|
{
|
|
private readonly IApplication _app;
|
|
private readonly MainWindow _mainWindow;
|
|
private readonly ChatMessageManager _messageManager;
|
|
private readonly CommandHandler _commandHandler;
|
|
private readonly NotificationSoundService _notificationSound;
|
|
private readonly AudioPlaybackService _audioPlayback = new();
|
|
private readonly UpdateChecker _updateService;
|
|
private readonly ConnectionManager _conn = new();
|
|
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Lock _channelUsersLock = new();
|
|
|
|
private ClientConfig _config;
|
|
private readonly UserSession _session = new();
|
|
|
|
public MainWindow MainWindow => _mainWindow;
|
|
|
|
public AppOrchestrator(IApplication app, ClientConfig config)
|
|
{
|
|
_app = app;
|
|
_config = config;
|
|
_messageManager = new ChatMessageManager();
|
|
_mainWindow = new MainWindow(app, _messageManager);
|
|
_commandHandler = new CommandHandler();
|
|
_notificationSound = new NotificationSoundService(config.Notifications);
|
|
_updateService = new UpdateChecker(app);
|
|
|
|
WireMainWindowEvents();
|
|
WireCommandHandlerEvents();
|
|
WireConnectionManagerEvents();
|
|
|
|
_updateService.Start();
|
|
|
|
_mainWindow.UpdateStatusBar("Disconnected");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
_updateService.Dispose();
|
|
}
|
|
|
|
// ── Convenience Helpers ────────────────────────────────────────────────
|
|
|
|
private void RunAsync(Func<Task> work, string errorPrefix, string? logContext = null)
|
|
{
|
|
AsyncRunner.Run(_app, work, _mainWindow.ShowError, errorPrefix, logContext);
|
|
}
|
|
|
|
private void InvokeUI(Action action) => _app.Invoke(action);
|
|
|
|
// ── MainWindow Event Wiring ────────────────────────────────────────────
|
|
|
|
private void WireMainWindowEvents()
|
|
{
|
|
_mainWindow.OnConnectRequested += HandleConnect;
|
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
|
_mainWindow.OnLogoutRequested += HandleLogout;
|
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
|
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
|
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
|
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
|
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
|
}
|
|
|
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
|
|
|
private void WireCommandHandlerEvents()
|
|
{
|
|
_commandHandler.OnSetStatus += HandleCmdSetStatus;
|
|
_commandHandler.OnSetNick += HandleCmdSetNick;
|
|
_commandHandler.OnSetColor += HandleCmdSetColor;
|
|
_commandHandler.OnSetTheme += HandleCmdSetTheme;
|
|
_commandHandler.OnSendFile += HandleCmdSendFile;
|
|
_commandHandler.OnSetAvatar += HandleCmdSetAvatar;
|
|
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
|
|
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
|
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
|
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
|
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
|
_commandHandler.OnListUsers += HandleCmdListUsers;
|
|
_commandHandler.OnKickUser += HandleCmdKickUser;
|
|
_commandHandler.OnBanUser += HandleCmdBanUser;
|
|
_commandHandler.OnUnbanUser += HandleCmdUnbanUser;
|
|
_commandHandler.OnMuteUser += HandleCmdMuteUser;
|
|
_commandHandler.OnUnmuteUser += HandleCmdUnmuteUser;
|
|
_commandHandler.OnAssignRole += HandleCmdAssignRole;
|
|
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
|
_commandHandler.OnTestSound += HandleCmdTestSound;
|
|
_commandHandler.OnQuit += HandleCmdQuit;
|
|
}
|
|
|
|
// ── Command Handlers ──────────────────────────────────────────────────
|
|
|
|
private async Task HandleCmdSetStatus(UserStatus status, string? message)
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
await _conn.UpdateStatusAsync(status, message);
|
|
_session.Status = status;
|
|
_session.StatusMessage = message;
|
|
}
|
|
|
|
private async Task HandleCmdSetNick(string displayName)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName));
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.SetCurrentUser(displayName);
|
|
_mainWindow.UpdateStatusBar("Connected");
|
|
});
|
|
}
|
|
|
|
private async Task HandleCmdSetColor(string color)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color));
|
|
}
|
|
|
|
private Task HandleCmdSetTheme(string name)
|
|
{
|
|
InvokeUI(() => HandleThemeSelected(name));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task HandleCmdSendFile(string target, string? size)
|
|
{
|
|
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
|
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel)) return;
|
|
|
|
try
|
|
{
|
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
|
{
|
|
await _conn.Api!.SendUrlAsync(channel, target, size);
|
|
}
|
|
else
|
|
{
|
|
await using var stream = File.OpenRead(target);
|
|
var fileName = Path.GetFileName(target);
|
|
await _conn.Api!.UploadFileAsync(channel, stream, fileName, size);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "File send failed for {Target}", target);
|
|
InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private async Task HandleCmdSetAvatar(string target)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
try
|
|
{
|
|
await AvatarHelper.UploadAsync(_conn.Api!, target);
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(channel))
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated."));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Avatar upload failed for {Target}", target);
|
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private Task HandleCmdOpenProfile(string? username)
|
|
{
|
|
InvokeUI(() => HandleViewProfile(username));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private Task HandleCmdOpenServers()
|
|
{
|
|
InvokeUI(HandleSavedServersRequested);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task HandleCmdJoinChannel(string channelName)
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
try
|
|
{
|
|
var history = await _conn.JoinChannelAsync(channelName);
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.EnsureChannelInList(channelName);
|
|
_mainWindow.SwitchToChannel(channelName);
|
|
if (history.Count > 0)
|
|
_messageManager.LoadHistory(channelName, history);
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"Failed to join channel: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private async Task HandleCmdLeaveChannel()
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel)) return;
|
|
|
|
if (channel == HubConstants.DefaultChannel)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _conn.LeaveChannelAsync(channel);
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"Failed to leave channel: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private async Task HandleCmdSetTopic(string topic)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel)) return;
|
|
|
|
try
|
|
{
|
|
await _conn.Api!.UpdateChannelTopicAsync(channel, topic);
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.SetChannelTopic(channel, topic);
|
|
_messageManager.AddSystemMessage(channel, $"Topic set to: {topic}");
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"Failed to set topic: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private async Task HandleCmdListUsers()
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel)) return;
|
|
|
|
try
|
|
{
|
|
var users = await _conn.GetOnlineUsersAsync(channel);
|
|
InvokeUI(() =>
|
|
{
|
|
_messageManager.AddSystemMessage(channel, $"Online users in #{channel}:");
|
|
foreach (var user in users)
|
|
{
|
|
var displayName = user.DisplayName ?? user.Username;
|
|
var statusText = user.Status.ToString();
|
|
if (!string.IsNullOrWhiteSpace(user.StatusMessage))
|
|
statusText += $" - {user.StatusMessage}";
|
|
_messageManager.AddSystemMessage(channel, $" {displayName} ({statusText})");
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"Failed to list users: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
private async Task HandleCmdKickUser(string username, string? reason)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.KickUserAsync(username, reason);
|
|
}
|
|
|
|
private async Task HandleCmdBanUser(string username, string? reason)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.BanUserAsync(username, reason);
|
|
}
|
|
|
|
private async Task HandleCmdUnbanUser(string username)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.UnbanUserAsync(username);
|
|
}
|
|
|
|
private async Task HandleCmdMuteUser(string username, int? duration)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.MuteUserAsync(username, duration);
|
|
}
|
|
|
|
private async Task HandleCmdUnmuteUser(string username)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
await _conn.Api!.UnmuteUserAsync(username);
|
|
}
|
|
|
|
private async Task HandleCmdAssignRole(string username, string roleStr)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
var role = roleStr switch
|
|
{
|
|
"admin" => ServerRole.Admin,
|
|
"mod" => ServerRole.Mod,
|
|
_ => ServerRole.Member,
|
|
};
|
|
await _conn.Api!.AssignRoleAsync(username, role);
|
|
}
|
|
|
|
private async Task HandleCmdNukeChannel()
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel)) return;
|
|
await _conn.Api!.NukeChannelAsync(channel);
|
|
}
|
|
|
|
private async Task HandleCmdTestSound()
|
|
{
|
|
await _notificationSound.PlayTestAsync();
|
|
}
|
|
|
|
private Task HandleCmdQuit()
|
|
{
|
|
InvokeUI(() => _app.RequestStop());
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ── ConnectionManager Event Wiring ─────────────────────────────────────
|
|
|
|
private void WireConnectionManagerEvents()
|
|
{
|
|
_conn.MessageReceived += message =>
|
|
{
|
|
InvokeUI(() => _messageManager.AddMessage(message));
|
|
|
|
if (!string.IsNullOrEmpty(_session.Username)
|
|
&& message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_ = _notificationSound.PlayAsync();
|
|
}
|
|
};
|
|
|
|
_conn.UserJoined += (channelName, username, presence) =>
|
|
{
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
|
|
|
|
List<UserPresenceDto>? snapshot = null;
|
|
lock (_channelUsersLock)
|
|
{
|
|
if (presence is not null && _channelUsers.TryGetValue(channelName, out var users))
|
|
{
|
|
if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)))
|
|
users.Add(presence);
|
|
|
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
|
snapshot = [.. users];
|
|
}
|
|
}
|
|
|
|
if (snapshot is not null)
|
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
|
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
|
FetchAndUpdateOnlineUsers();
|
|
};
|
|
|
|
_conn.UserLeft += (channelName, username) =>
|
|
{
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
|
|
|
|
List<UserPresenceDto>? snapshot = null;
|
|
lock (_channelUsersLock)
|
|
{
|
|
if (_channelUsers.TryGetValue(channelName, out var users))
|
|
{
|
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
|
snapshot = [.. users];
|
|
}
|
|
}
|
|
|
|
if (snapshot is not null)
|
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
|
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
|
FetchAndUpdateOnlineUsers();
|
|
};
|
|
|
|
_conn.UserStatusChanged += presence =>
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
var displayName = presence.DisplayName ?? presence.Username;
|
|
var statusText = presence.Status.ToString();
|
|
if (!string.IsNullOrWhiteSpace(presence.StatusMessage))
|
|
statusText += $" - {presence.StatusMessage}";
|
|
|
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
|
_messageManager.AddStatusMessage(channelName, displayName, statusText);
|
|
});
|
|
|
|
// Update presence in all cached channel lists
|
|
List<UserPresenceDto>? snapshot = null;
|
|
lock (_channelUsersLock)
|
|
{
|
|
foreach (var (channel, users) in _channelUsers)
|
|
{
|
|
var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase));
|
|
if (idx >= 0)
|
|
{
|
|
if (presence.Status == UserStatus.Invisible)
|
|
users.RemoveAt(idx);
|
|
else
|
|
users[idx] = presence;
|
|
}
|
|
else if (presence.Status != UserStatus.Invisible)
|
|
{
|
|
// User came back from invisible — re-add them
|
|
users.Add(presence);
|
|
}
|
|
}
|
|
|
|
var currentChannel = _mainWindow.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
|
|
snapshot = [.. currentUsers];
|
|
}
|
|
|
|
if (snapshot is not null)
|
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
|
};
|
|
|
|
_conn.UserKicked += (channelName, username, reason) =>
|
|
{
|
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
|
|
|
|
List<UserPresenceDto>? snapshot = null;
|
|
lock (_channelUsersLock)
|
|
{
|
|
if (_channelUsers.TryGetValue(channelName, out var users))
|
|
{
|
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
|
|
snapshot = [.. users];
|
|
}
|
|
}
|
|
|
|
if (snapshot is not null)
|
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
|
|
};
|
|
|
|
_conn.UserBanned += (username, reason) =>
|
|
{
|
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
|
|
|
List<UserPresenceDto>? snapshot = null;
|
|
lock (_channelUsersLock)
|
|
{
|
|
// Remove banned user from all cached channel lists
|
|
foreach (var (channel, users) in _channelUsers)
|
|
{
|
|
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
var currentChannel = _mainWindow.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
|
|
snapshot = [.. currentUsers];
|
|
}
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(channel))
|
|
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
|
|
|
if (snapshot is not null)
|
|
_mainWindow.UpdateOnlineUsers(snapshot);
|
|
}
|
|
});
|
|
};
|
|
|
|
_conn.ForceDisconnected += reason =>
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.ShowError(reason);
|
|
HandleDisconnect();
|
|
});
|
|
};
|
|
|
|
_conn.MessageDeleted += (channelName, messageId) =>
|
|
InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
|
|
|
|
_conn.ChannelNuked += channelName =>
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
_messageManager.ClearChannelMessages(channelName);
|
|
_messageManager.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
|
|
});
|
|
};
|
|
|
|
_conn.ChannelUpdated += channel =>
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
if (channel.IsPublic)
|
|
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
|
});
|
|
};
|
|
|
|
_conn.Error += errorMessage =>
|
|
InvokeUI(() => _mainWindow.ShowError(errorMessage));
|
|
|
|
_conn.ConnectionStatusChanged += status =>
|
|
InvokeUI(() => _mainWindow.UpdateStatusBar(status));
|
|
|
|
_conn.Reconnected += () =>
|
|
{
|
|
lock (_channelUsersLock) _channelUsers.Clear();
|
|
RunAsync(
|
|
async () => await _conn.RejoinChannelsAsync(),
|
|
"Failed to rejoin channels after reconnect");
|
|
};
|
|
}
|
|
|
|
// ── MainWindow Event Handlers ──────────────────────────────────────────
|
|
|
|
private void HandleConnect()
|
|
{
|
|
if (_conn.IsConnected)
|
|
{
|
|
var confirm = MessageBox.Query(_app, "Already Connected",
|
|
"You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel");
|
|
|
|
if (confirm != 0) return;
|
|
|
|
HandleDisconnect();
|
|
}
|
|
|
|
var dialogResult = ConnectDialog.Show(_app, _config.SavedServers);
|
|
if (dialogResult is null) return;
|
|
|
|
Log.Information("Connecting to {Url} as {User} (register={IsRegister})",
|
|
dialogResult.ServerUrl, dialogResult.Username, dialogResult.IsRegister);
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
ConnectResult result;
|
|
try
|
|
{
|
|
result = await _conn.ConnectAsync(dialogResult,
|
|
status => InvokeUI(() => _mainWindow.UpdateStatusBar(status)));
|
|
}
|
|
catch (Exception ex) when (dialogResult.SavedRefreshToken is not null)
|
|
{
|
|
Log.Warning(ex, "Saved session expired or revoked");
|
|
ClearSavedToken(dialogResult.ServerUrl);
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.UpdateStatusBar("Disconnected");
|
|
MessageBox.ErrorQuery(_app, "Session Expired",
|
|
"Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
|
|
});
|
|
return;
|
|
}
|
|
|
|
_session.Username = result.Login.Username;
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
|
|
_mainWindow.SetChannels(result.Channels);
|
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
|
if (result.DefaultHistory.Count > 0)
|
|
_messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
|
|
_mainWindow.FocusInput();
|
|
FetchAndUpdateOnlineUsers();
|
|
});
|
|
SaveServerToConfig(dialogResult);
|
|
}, "Connection failed", "Connect");
|
|
}
|
|
|
|
private void HandleDisconnect()
|
|
{
|
|
Log.Information("Disconnecting from server");
|
|
lock (_channelUsersLock) _channelUsers.Clear();
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
await _conn.CleanupAsync();
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.ClearAll();
|
|
_mainWindow.UpdateStatusBar("Disconnected");
|
|
});
|
|
}, "Disconnect error", "Disconnect");
|
|
}
|
|
|
|
private void HandleLogout()
|
|
{
|
|
Log.Information("Logging out from server");
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
var baseUrl = _conn.Api?.BaseUrl;
|
|
await _conn.LogoutAsync();
|
|
|
|
if (baseUrl is not null)
|
|
ClearSavedToken(baseUrl);
|
|
|
|
await _conn.CleanupAsync();
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.ClearAll();
|
|
_mainWindow.UpdateStatusBar("Disconnected");
|
|
});
|
|
}, "Logout error", "Logout");
|
|
}
|
|
|
|
private void HandleMessageSubmitted(string channelName, string content)
|
|
{
|
|
if (!_conn.IsConnected)
|
|
{
|
|
_mainWindow.ShowError("Not connected to a server.");
|
|
return;
|
|
}
|
|
|
|
if (_commandHandler.IsCommand(content))
|
|
{
|
|
RunAsync(async () =>
|
|
{
|
|
var result = await _commandHandler.HandleAsync(content);
|
|
if (result.Message is not null)
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
if (result.IsError)
|
|
_mainWindow.ShowError(result.Message);
|
|
else
|
|
_messageManager.AddSystemMessage(channelName, result.Message);
|
|
});
|
|
}
|
|
}, "Command failed");
|
|
return;
|
|
}
|
|
|
|
RunAsync(
|
|
async () => await _conn.SendMessageAsync(channelName, content),
|
|
"Send failed");
|
|
}
|
|
|
|
private void HandleChannelSelected(string channelName)
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
if (_conn.TrackChannel(channelName))
|
|
await _conn.JoinChannelAsync(channelName);
|
|
|
|
try
|
|
{
|
|
var history = await _conn.GetHistoryAsync(channelName);
|
|
InvokeUI(() => _messageManager.LoadHistory(channelName, history));
|
|
}
|
|
catch
|
|
{
|
|
// History might not be available
|
|
}
|
|
|
|
FetchAndUpdateOnlineUsers();
|
|
}, "Failed to join channel");
|
|
}
|
|
|
|
private void HandleChannelJoinFromMessage(string channelName)
|
|
{
|
|
if (!_conn.IsConnected) return;
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.EnsureChannelInList(channelName);
|
|
_mainWindow.SwitchToChannel(channelName);
|
|
});
|
|
|
|
HandleChannelSelected(channelName);
|
|
}
|
|
|
|
private void HandleProfileRequested()
|
|
{
|
|
HandleViewProfile(null);
|
|
}
|
|
|
|
private void HandleViewProfile(string? username)
|
|
{
|
|
var isOwnProfile = string.IsNullOrWhiteSpace(username)
|
|
|| username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase);
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
UserProfileDto? profile = null;
|
|
try
|
|
{
|
|
if (_conn.IsAuthenticated)
|
|
{
|
|
var target = isOwnProfile ? _session.Username : username!;
|
|
if (!string.IsNullOrEmpty(target))
|
|
profile = await _conn.Api!.GetUserProfileAsync(target);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
InvokeUI(() => _mainWindow.ShowError($"Failed to load profile: {ex.Message}"));
|
|
return;
|
|
}
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
if (isOwnProfile)
|
|
{
|
|
var action = ProfileViewDialog.ShowOwn(_app,
|
|
profile,
|
|
_session.Status,
|
|
_session.StatusMessage);
|
|
|
|
switch (action)
|
|
{
|
|
case ProfileAction.EditProfile:
|
|
HandleEditProfile(profile);
|
|
break;
|
|
case ProfileAction.SetStatus:
|
|
HandleStatusRequested();
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ProfileViewDialog.Show(_app, profile);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private void HandleEditProfile(UserProfileDto? currentProfile)
|
|
{
|
|
var editResult = ProfileEditDialog.Show(_app,
|
|
currentProfile?.DisplayName,
|
|
currentProfile?.Bio,
|
|
currentProfile?.NicknameColor,
|
|
_config.Notifications.Enabled,
|
|
_config.Notifications.Volume);
|
|
|
|
if (editResult is null) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(
|
|
editResult.DisplayName,
|
|
editResult.Bio,
|
|
editResult.NicknameColor));
|
|
|
|
if (editResult.DisplayName is not null)
|
|
{
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.SetCurrentUser(editResult.DisplayName);
|
|
_mainWindow.UpdateStatusBar("Connected");
|
|
});
|
|
}
|
|
|
|
// Upload avatar if specified
|
|
if (editResult.AvatarPath is not null)
|
|
{
|
|
try
|
|
{
|
|
await AvatarHelper.UploadAsync(_conn.Api!, editResult.AvatarPath);
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(channel))
|
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated."));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath);
|
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
|
}
|
|
}
|
|
|
|
if (editResult.NotificationSoundEnabled.HasValue)
|
|
{
|
|
_config.Notifications.Enabled = editResult.NotificationSoundEnabled.Value;
|
|
_notificationSound.SetEnabled(editResult.NotificationSoundEnabled.Value);
|
|
}
|
|
|
|
if (editResult.NotificationVolume.HasValue)
|
|
{
|
|
_config.Notifications.Volume = editResult.NotificationVolume.Value;
|
|
_notificationSound.SetVolume(editResult.NotificationVolume.Value);
|
|
}
|
|
|
|
_config.DefaultPreset = new AccountPreset
|
|
{
|
|
DisplayName = editResult.DisplayName,
|
|
Bio = editResult.Bio,
|
|
NicknameColor = editResult.NicknameColor
|
|
};
|
|
ConfigManager.Save(_config);
|
|
}, "Profile update failed");
|
|
}
|
|
|
|
private void HandleStatusRequested()
|
|
{
|
|
var result = StatusDialog.Show(_app, _session.Status, _session.StatusMessage);
|
|
if (result is null) return;
|
|
|
|
_session.Status = result.Status;
|
|
_session.StatusMessage = result.StatusMessage;
|
|
|
|
if (_conn.IsConnected)
|
|
{
|
|
RunAsync(
|
|
async () => await _conn.UpdateStatusAsync(result.Status, result.StatusMessage),
|
|
"Status update failed");
|
|
}
|
|
}
|
|
|
|
private void HandleThemeSelected(string themeName)
|
|
{
|
|
Log.Information("Theme selected: {Theme}", themeName);
|
|
|
|
var theme = ThemeManager.GetTheme(themeName);
|
|
ThemeManager.ApplyTheme(theme);
|
|
|
|
_config.ActiveTheme = themeName;
|
|
ConfigManager.Save(_config);
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.ApplyColorSchemes();
|
|
_mainWindow.SetNeedsDraw();
|
|
Log.Debug("Theme applied and UI refreshed");
|
|
});
|
|
}
|
|
|
|
private void HandleSavedServersRequested()
|
|
{
|
|
if (_config.SavedServers.Count == 0)
|
|
{
|
|
MessageBox.Query(_app, "Saved Servers",
|
|
"No saved servers yet.\nConnect to a server to save it automatically.", "OK");
|
|
return;
|
|
}
|
|
|
|
var serverLines = _config.SavedServers
|
|
.Select(s =>
|
|
{
|
|
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : "";
|
|
return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}";
|
|
})
|
|
.ToList();
|
|
|
|
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
|
|
}
|
|
|
|
private void HandleCreateChannelRequested()
|
|
{
|
|
if (!_conn.IsAuthenticated || !_conn.IsConnected)
|
|
{
|
|
_mainWindow.ShowError("Not connected to a server.");
|
|
return;
|
|
}
|
|
|
|
var result = CreateChannelDialog.Show(_app);
|
|
if (result is null) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
|
if (channel is null) return;
|
|
|
|
var history = await _conn.JoinChannelAsync(channel.Name);
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.EnsureChannelInList(channel.Name);
|
|
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
|
_mainWindow.SwitchToChannel(channel.Name);
|
|
if (history.Count > 0)
|
|
_messageManager.LoadHistory(channel.Name, history);
|
|
});
|
|
|
|
FetchAndUpdateOnlineUsers();
|
|
}, "Failed to create channel");
|
|
}
|
|
|
|
private void HandleDeleteChannelRequested()
|
|
{
|
|
if (!_conn.IsAuthenticated || !_conn.IsConnected)
|
|
{
|
|
_mainWindow.ShowError("Not connected to a server.");
|
|
return;
|
|
}
|
|
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel))
|
|
{
|
|
_mainWindow.ShowError("No channel selected.");
|
|
return;
|
|
}
|
|
|
|
if (channel == HubConstants.DefaultChannel)
|
|
{
|
|
_mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted.");
|
|
return;
|
|
}
|
|
|
|
var confirm = MessageBox.Query(_app, "Delete Channel",
|
|
$"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel");
|
|
|
|
if (confirm != 0) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
await _conn.Api!.DeleteChannelAsync(channel);
|
|
_conn.UntrackChannel(channel);
|
|
|
|
InvokeUI(() =>
|
|
{
|
|
_mainWindow.RemoveChannel(channel);
|
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
|
_messageManager.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
|
|
});
|
|
}, "Failed to delete channel");
|
|
}
|
|
|
|
private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
|
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
|
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
|
}, "Failed to play audio");
|
|
}
|
|
|
|
/// <summary>
|
|
/// File extensions considered safe to open with the system default application.
|
|
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
|
/// </summary>
|
|
private static readonly HashSet<string> SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".mp4", ".webm", ".mkv", ".avi", ".mov", // video
|
|
".pdf", ".txt", ".csv", ".json", ".xml", // documents
|
|
};
|
|
|
|
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
|
{
|
|
if (!_conn.IsAuthenticated) return;
|
|
|
|
RunAsync(async () =>
|
|
{
|
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
|
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
|
|
|
var ext = Path.GetExtension(fileName);
|
|
if (SafeOpenExtensions.Contains(ext))
|
|
{
|
|
try
|
|
{
|
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
|
System.Diagnostics.Process.Start(psi);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
|
}
|
|
}, "Failed to download file");
|
|
}
|
|
|
|
private void HandleCheckForUpdatesRequested()
|
|
{
|
|
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates");
|
|
}
|
|
|
|
private void HandleRollbackRequested()
|
|
{
|
|
if (!UpdateBackupService.BackupExists())
|
|
{
|
|
MessageBox.ErrorQuery(_app, "No Backup", "No backup is available to restore.", "OK");
|
|
return;
|
|
}
|
|
|
|
var info = UpdateBackupService.GetBackupInfo();
|
|
var confirm = MessageBox.Query(_app, "Rollback Update",
|
|
$"Restore to version {info?.Version ?? "unknown"}?\n\nThe app will restart.", "Restore", "Cancel");
|
|
|
|
if (confirm != 0) return;
|
|
|
|
try
|
|
{
|
|
UpdateBackupService.RestoreBackup();
|
|
// RestoreBackup calls Environment.Exit(0)
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Rollback failed");
|
|
MessageBox.ErrorQuery(_app, "Rollback Failed", $"Could not restore: {ex.Message}", "OK");
|
|
}
|
|
}
|
|
|
|
// ── Private Helpers ────────────────────────────────────────────────────
|
|
|
|
private void FetchAndUpdateOnlineUsers()
|
|
{
|
|
var channel = _mainWindow.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel) || !_conn.IsConnected) return;
|
|
|
|
Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
var users = await _conn.GetOnlineUsersAsync(channel);
|
|
lock (_channelUsersLock) _channelUsers[channel] = users;
|
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug(ex, "Failed to fetch online users for {Channel}", channel);
|
|
}
|
|
});
|
|
}
|
|
|
|
private void SaveServerToConfig(ConnectDialogResult result)
|
|
{
|
|
var savedServer = new SavedServer
|
|
{
|
|
Name = new Uri(result.ServerUrl).Host,
|
|
Url = result.ServerUrl,
|
|
Username = result.Username,
|
|
RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
|
|
RememberMe = result.RememberMe,
|
|
LastConnected = DateTimeOffset.Now
|
|
};
|
|
ConfigManager.SaveServer(savedServer);
|
|
_config = ConfigManager.Load();
|
|
Log.Information("Connected successfully to {Url}", result.ServerUrl);
|
|
}
|
|
|
|
private void ClearSavedToken(string serverUrl)
|
|
{
|
|
var config = ConfigManager.Load();
|
|
var server = config.SavedServers.FirstOrDefault(s =>
|
|
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
|
|
if (server is not null)
|
|
{
|
|
server.RefreshToken = null;
|
|
ConfigManager.Save(config);
|
|
_config = config;
|
|
}
|
|
}
|
|
}
|