mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: enhance user presence and channel interaction features
- 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.
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures)
|
- Fix connection failure cleanup — `ConnectionManager.ConnectAsync` now properly disposes `ApiClient` and `EchoHubConnection` on any failure path (previously only cleaned up on saved-token auth failures)
|
||||||
- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients
|
- Fix IRC gateway sending UTF-8 BOM on first message, breaking CAP negotiation and SASL auth for all clients
|
||||||
- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64
|
- Handle `AUTHENTICATE *` (SASL abort) instead of crashing on invalid base64
|
||||||
|
- Fix userlist not refreshing when creating a new channel — now fetches online users after channel creation
|
||||||
|
- Fix unmute timer not working — add `MuteExpirationService` background job that proactively unmutes users when their timed mute expires (previously only checked on message send)
|
||||||
|
- Fix missing space between mod/admin role icon and username in the userlist
|
||||||
|
- Fix invisible users still visible in the userlist — `GetOnlineUsersAsync` now filters invisible users; server skips `UserJoined` broadcast for invisible users
|
||||||
|
- Fix invisible→online transition — user reappears in cached userlist when switching from invisible back to a visible status
|
||||||
|
- Fix thread safety — `_channelUsers` presence cache now protected by `Lock` to prevent races between SignalR events and background fetches
|
||||||
|
- Fix `@mention` regex matching email addresses and `#channel` regex matching hex colors / issue numbers — both now use lookbehind and letter-requirement guards
|
||||||
|
- Fix `ParseThemeColor` accepting non-hex characters — now validates `[0-9a-fA-F]` digits
|
||||||
|
|
||||||
## New Features
|
## New Features
|
||||||
|
|
||||||
@@ -15,17 +23,31 @@
|
|||||||
- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag
|
- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag
|
||||||
- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately
|
- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately
|
||||||
- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth)
|
- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth)
|
||||||
|
- Clickable usernames — press Enter on a username in the userlist or message sender to view their profile
|
||||||
|
- Clickable @mentions — press Enter on a message containing `@username` to open that user's profile
|
||||||
|
- Clickable #channels — press Enter on a message containing `#channel` to join/switch to that channel; `#channel` references are now highlighted in chat
|
||||||
|
- Embed theme colors — embed vertical border line now uses the source site's `theme-color` meta tag when available
|
||||||
|
- Stateful userlist — user presence is cached per channel and updated incrementally via SignalR events instead of re-fetching the full list on every join/leave/status change
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Restrict file auto-open — only safe file types (video, PDF, text) are opened with the system default app; all other files are downloaded to temp without executing (prevents script execution via `.bat`, `.exe`, etc.)
|
||||||
|
|
||||||
## Refactoring
|
## Refactoring
|
||||||
|
|
||||||
- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService`
|
- Extract `IUserService`/`UserService` — consolidate user registration, authentication, and profile management into a dedicated service, eliminating duplicated logic between `AuthController` and `ChatService`
|
||||||
- IRC gateway now checks ban status during authentication (previously skipped)
|
- IRC gateway now checks ban status during authentication (previously skipped)
|
||||||
|
- EchoHubSpace directory updates — server now only sends user count when it actually changes instead of every 30 seconds
|
||||||
|
|
||||||
## Distribution
|
## Distribution
|
||||||
|
|
||||||
- Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release
|
- Add Chocolatey package — `choco install echohub` for Windows users, auto-published from CI on each release
|
||||||
- Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection
|
- Add Linux/macOS install script — `curl -sSfL .../install.sh | sh` with automatic OS/arch detection
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Add Flows section — Mermaid sequence diagrams documenting all major request/event flows (auth, connection, messaging, channels, moderation, file upload, link embeds, server directory) with inline code references
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release
|
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly AudioPlaybackService _audioPlayback = new();
|
private readonly AudioPlaybackService _audioPlayback = new();
|
||||||
private readonly UpdateChecker _updateService;
|
private readonly UpdateChecker _updateService;
|
||||||
private readonly ConnectionManager _conn = new();
|
private readonly ConnectionManager _conn = new();
|
||||||
|
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Lock _channelUsersLock = new();
|
||||||
|
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private readonly UserSession _session = new();
|
private readonly UserSession _session = new();
|
||||||
@@ -87,6 +89,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
|
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||||
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -381,17 +385,48 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserJoined += (channelName, username) =>
|
_conn.UserJoined += (channelName, username, presence) =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
if (channelName == _mainWindow.CurrentChannel)
|
|
||||||
|
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();
|
FetchAndUpdateOnlineUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
_conn.UserLeft += (channelName, username) =>
|
_conn.UserLeft += (channelName, username) =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
|
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
if (channelName == _mainWindow.CurrentChannel)
|
|
||||||
|
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();
|
FetchAndUpdateOnlineUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,18 +442,75 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||||
_messageManager.AddStatusMessage(channelName, displayName, statusText);
|
_messageManager.AddStatusMessage(channelName, displayName, statusText);
|
||||||
});
|
});
|
||||||
FetchAndUpdateOnlineUsers();
|
|
||||||
|
// 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) =>
|
_conn.UserKicked += (channelName, username, reason) =>
|
||||||
{
|
{
|
||||||
var reasonText = reason is not null ? $" ({reason})" : "";
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
|
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) =>
|
_conn.UserBanned += (username, reason) =>
|
||||||
{
|
{
|
||||||
var reasonText = reason is not null ? $" ({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(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -426,6 +518,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
var channel = _mainWindow.CurrentChannel;
|
var channel = _mainWindow.CurrentChannel;
|
||||||
if (!string.IsNullOrEmpty(channel))
|
if (!string.IsNullOrEmpty(channel))
|
||||||
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
|
||||||
|
|
||||||
|
if (snapshot is not null)
|
||||||
|
_mainWindow.UpdateOnlineUsers(snapshot);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -469,6 +564,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_conn.Reconnected += () =>
|
_conn.Reconnected += () =>
|
||||||
{
|
{
|
||||||
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
RunAsync(
|
RunAsync(
|
||||||
async () => await _conn.RejoinChannelsAsync(),
|
async () => await _conn.RejoinChannelsAsync(),
|
||||||
"Failed to rejoin channels after reconnect");
|
"Failed to rejoin channels after reconnect");
|
||||||
@@ -535,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private void HandleDisconnect()
|
private void HandleDisconnect()
|
||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -623,6 +720,19 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to join channel");
|
}, "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()
|
private void HandleProfileRequested()
|
||||||
{
|
{
|
||||||
HandleViewProfile(null);
|
HandleViewProfile(null);
|
||||||
@@ -826,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (history.Count > 0)
|
if (history.Count > 0)
|
||||||
_messageManager.LoadHistory(channel.Name, history);
|
_messageManager.LoadHistory(channel.Name, history);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
}, "Failed to create channel");
|
}, "Failed to create channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,6 +993,16 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to play audio");
|
}, "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)
|
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -890,14 +1012,22 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||||
|
|
||||||
try
|
var ext = Path.GetExtension(fileName);
|
||||||
|
if (SafeOpenExtensions.Contains(ext))
|
||||||
{
|
{
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
try
|
||||||
System.Diagnostics.Process.Start(psi);
|
{
|
||||||
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
|
|
||||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
|
||||||
}
|
}
|
||||||
}, "Failed to download file");
|
}, "Failed to download file");
|
||||||
@@ -946,6 +1076,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var users = await _conn.GetOnlineUsersAsync(channel);
|
var users = await _conn.GetOnlineUsersAsync(channel);
|
||||||
|
lock (_channelUsersLock) _channelUsers[channel] = users;
|
||||||
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
public event Action<MessageDto>? MessageReceived;
|
public event Action<MessageDto>? MessageReceived;
|
||||||
public event Action<string, string>? UserJoined;
|
public event Action<string, string, UserPresenceDto?>? UserJoined;
|
||||||
public event Action<string, string>? UserLeft;
|
public event Action<string, string>? UserLeft;
|
||||||
public event Action<UserPresenceDto>? UserStatusChanged;
|
public event Action<UserPresenceDto>? UserStatusChanged;
|
||||||
public event Action<string, string, string?>? UserKicked;
|
public event Action<string, string, string?>? UserKicked;
|
||||||
@@ -234,7 +234,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
{
|
{
|
||||||
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
||||||
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
|
connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence);
|
||||||
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
||||||
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
||||||
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
private readonly ClientEncryptionService _encryption;
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string>? OnUserJoined;
|
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
|
||||||
public event Action<string, string>? OnUserLeft;
|
public event Action<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
@@ -70,9 +70,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnMessageReceived?.Invoke(decrypted);
|
OnMessageReceived?.Invoke(decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
|
||||||
{
|
{
|
||||||
OnUserJoined?.Invoke(channelName, username);
|
OnUserJoined?.Invoke(channelName, username, presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
||||||
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
||||||
|
public static readonly Attribute ChannelRefAttr = new(new Color(100, 200, 255), Color.None);
|
||||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
@@ -21,8 +22,8 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||||
/// Non-mention text uses the provided default color.
|
/// Non-special text uses the provided default color.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||||
{
|
{
|
||||||
@@ -38,12 +39,40 @@ public static partial class ChatColors
|
|||||||
lastIndex = match.Index + match.Length;
|
lastIndex = match.Index + match.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
// Second pass: highlight #channels in non-mention segments
|
||||||
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
var mentionSegments = segments;
|
||||||
|
segments = [];
|
||||||
|
foreach (var seg in mentionSegments)
|
||||||
|
{
|
||||||
|
if (seg.Color != null && seg.Color != defaultColor)
|
||||||
|
{
|
||||||
|
// Already colored (mention) — keep as-is
|
||||||
|
segments.Add(seg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int segLast = 0;
|
||||||
|
foreach (Match match in ChannelRefRegex().Matches(seg.Text))
|
||||||
|
{
|
||||||
|
if (match.Index > segLast)
|
||||||
|
segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor));
|
||||||
|
|
||||||
|
segments.Add(new ChatSegment(match.Value, ChannelRefAttr));
|
||||||
|
segLast = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segLast < seg.Text.Length)
|
||||||
|
segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor));
|
||||||
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"@[\w-]+")]
|
// @mention — not preceded by a word char (avoids emails)
|
||||||
|
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
|
||||||
private static partial Regex MentionRegex();
|
private static partial Regex MentionRegex();
|
||||||
|
|
||||||
|
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
|
||||||
|
[GeneratedRegex(@"(?<!\w)#(?=.*[a-zA-Z])[\w-]+")]
|
||||||
|
private static partial Regex ChannelRefRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public partial class ChatLine
|
|||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public MessageType? Type { get; set; }
|
public MessageType? Type { get; set; }
|
||||||
|
public string? SenderUsername { get; set; }
|
||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
@@ -118,13 +119,14 @@ public partial class ChatLine
|
|||||||
if (results.Count == 0)
|
if (results.Count == 0)
|
||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
// Propagate metadata to all wrapped lines so they remain clickable
|
||||||
foreach (var wrapped in results)
|
foreach (var wrapped in results)
|
||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
wrapped.AttachmentFileName = AttachmentFileName;
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
wrapped.Type = Type;
|
wrapped.Type = Type;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
|
wrapped.SenderUsername = SenderUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -272,7 +272,10 @@ public sealed class ChatMessageManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
line.MessageId = message.Id;
|
line.MessageId = message.Id;
|
||||||
|
line.SenderUsername = message.SenderUsername;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
{
|
{
|
||||||
@@ -329,18 +332,20 @@ public sealed class ChatMessageManager
|
|||||||
int textWidth = chatWidth - indentCols - borderCols;
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
if (textWidth < 20) textWidth = 20;
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
void AddTextLine(string text, Attribute? color)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine(
|
lines.Add(new ChatLine(
|
||||||
[
|
[
|
||||||
new ChatSegment(indent, null),
|
new ChatSegment(indent, null),
|
||||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
new ChatSegment(border, borderAttr),
|
||||||
new ChatSegment(text, color)
|
new ChatSegment(text, color)
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
AddTextLine(embed.SiteName, borderAttr);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserListSource : IListDataSource
|
public class UserListSource : IListDataSource
|
||||||
{
|
{
|
||||||
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
private readonly List<(string Text, Attribute? NameColor, string Username)> _users = [];
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
public int Count => _users.Count;
|
public int Count => _users.Count;
|
||||||
public int MaxItemLength { get; private set; }
|
public int MaxItemLength { get; private set; }
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
public void Update(List<(string Text, Attribute? NameColor)> users)
|
public void Update(List<(string Text, Attribute? NameColor, string Username)> users)
|
||||||
{
|
{
|
||||||
_users.Clear();
|
_users.Clear();
|
||||||
_users.AddRange(users);
|
_users.AddRange(users);
|
||||||
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
|
|||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? GetUsername(int index) =>
|
||||||
|
index >= 0 && index < _users.Count ? _users[index].Username : null;
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
public bool IsMarked(int item) => false;
|
||||||
public void SetMark(int item, bool value) { }
|
public void SetMark(int item, bool value) { }
|
||||||
public IList ToList() => _users.Select(u => u.Text).ToList();
|
public IList ToList() => _users.Select(u => (object)u.Text).ToList();
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
{
|
{
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
var (text, nameColor) = _users[item];
|
var (text, nameColor, _) = _users[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
// Find where the name starts (after status icon + space + optional role badge)
|
// Find where the name starts (after status icon + space + optional role badge)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.Services;
|
using EchoHub.Client.Services;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using EchoHub.Client.UI.Chat;
|
using EchoHub.Client.UI.Chat;
|
||||||
@@ -19,7 +20,7 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Main Terminal.Gui window for the EchoHub chat client.
|
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MainWindow : Runnable
|
public sealed partial class MainWindow : Runnable
|
||||||
{
|
{
|
||||||
private readonly IApplication _app;
|
private readonly IApplication _app;
|
||||||
private readonly ListView _channelList;
|
private readonly ListView _channelList;
|
||||||
@@ -140,6 +141,16 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnFileDownloadRequested;
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnUserProfileRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates a #channel reference in a message. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? OnChannelJoinRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -250,6 +261,7 @@ public sealed class MainWindow : Runnable
|
|||||||
};
|
};
|
||||||
_usersListSource = new UserListSource();
|
_usersListSource = new UserListSource();
|
||||||
_usersList.Source = _usersListSource;
|
_usersList.Source = _usersListSource;
|
||||||
|
_usersList.Accepting += OnUsersListAccepting;
|
||||||
_usersFrame.Add(_usersList);
|
_usersFrame.Add(_usersList);
|
||||||
Add(_usersFrame);
|
Add(_usersFrame);
|
||||||
|
|
||||||
@@ -410,17 +422,66 @@ public sealed class MainWindow : Runnable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var line = source.GetLine(index.Value);
|
var line = source.GetLine(index.Value);
|
||||||
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
if (line is null) return;
|
||||||
return;
|
|
||||||
|
|
||||||
if (line.Type == MessageType.Audio)
|
// Audio/file attachments take priority
|
||||||
|
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
||||||
{
|
{
|
||||||
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
if (line.Type == MessageType.Audio)
|
||||||
|
{
|
||||||
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Type == MessageType.File)
|
||||||
|
{
|
||||||
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineText = line.ToString();
|
||||||
|
|
||||||
|
// Check for @mention — open mentioned user's profile
|
||||||
|
// Negative lookbehind prevents matching emails (user@domain)
|
||||||
|
var mentionMatch = ClickMentionRegex().Match(lineText);
|
||||||
|
if (mentionMatch.Success)
|
||||||
|
{
|
||||||
|
OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for #channel — join/switch to that channel
|
||||||
|
// Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123)
|
||||||
|
var channelMatch = ClickChannelRegex().Match(lineText);
|
||||||
|
if (channelMatch.Success)
|
||||||
|
{
|
||||||
|
OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: open sender's profile
|
||||||
|
if (line.SenderUsername is not null)
|
||||||
|
{
|
||||||
|
OnUserProfileRequested?.Invoke(line.SenderUsername);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
else if (line.Type == MessageType.File)
|
}
|
||||||
|
|
||||||
|
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var index = _usersList.SelectedItem;
|
||||||
|
if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var username = _usersListSource.GetUsername(index.Value);
|
||||||
|
if (username is not null)
|
||||||
{
|
{
|
||||||
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
OnUserProfileRequested?.Invoke(username);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -862,14 +923,14 @@ public sealed class MainWindow : Runnable
|
|||||||
var name = u.DisplayName ?? u.Username;
|
var name = u.DisplayName ?? u.Username;
|
||||||
var roleTag = u.Role switch
|
var roleTag = u.Role switch
|
||||||
{
|
{
|
||||||
ServerRole.Owner => "\u2605", // ★
|
ServerRole.Owner => "\u2605 ", // ★
|
||||||
ServerRole.Admin => "\u2666", // ♦
|
ServerRole.Admin => "\u2666 ", // ♦
|
||||||
ServerRole.Mod => "\u2740", // ❀
|
ServerRole.Mod => "\u2740 ", // ❀
|
||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
var text = $"{statusIcon} {roleTag}{name}";
|
var text = $"{statusIcon} {roleTag}{name}";
|
||||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
return (text, nameColor);
|
return (text, nameColor, u.Username);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_usersListSource.Update(displayItems);
|
_usersListSource.Update(displayItems);
|
||||||
@@ -877,4 +938,11 @@ public sealed class MainWindow : Runnable
|
|||||||
_usersFrame.Title = $"Users ({users.Count})";
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @mention — not preceded by a word char (avoids emails)
|
||||||
|
[GeneratedRegex(@"(?<!\w)@([\w-]+)")]
|
||||||
|
private static partial Regex ClickMentionRegex();
|
||||||
|
|
||||||
|
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
|
||||||
|
[GeneratedRegex(@"(?<!\w)#((?=.*[a-zA-Z])[\w-]+)")]
|
||||||
|
private static partial Regex ClickChannelRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace EchoHub.Core.Contracts;
|
|||||||
public interface IChatBroadcaster
|
public interface IChatBroadcaster
|
||||||
{
|
{
|
||||||
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
||||||
Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
|
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace EchoHub.Core.Contracts;
|
|||||||
public interface IEchoHubClient
|
public interface IEchoHubClient
|
||||||
{
|
{
|
||||||
Task ReceiveMessage(MessageDto message);
|
Task ReceiveMessage(MessageDto message);
|
||||||
Task UserJoined(string channelName, string username);
|
Task UserJoined(string channelName, string username, UserPresenceDto? presence);
|
||||||
Task UserLeft(string channelName, string username);
|
Task UserLeft(string channelName, string username);
|
||||||
Task ChannelUpdated(ChannelDto channel);
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
Task UserStatusChanged(UserPresenceDto presence);
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
|||||||
@@ -44,4 +44,5 @@ public record EmbedDto(
|
|||||||
string? Title,
|
string? Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
string? ImageAscii,
|
string? ImageAscii,
|
||||||
string Url);
|
string Url,
|
||||||
|
string? ThemeColor = null);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ while (true)
|
|||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|
||||||
// ── Encryption ─────────────────────────────────────────────────────
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||||
|
|||||||
@@ -107,7 +107,31 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
if (isNewJoin)
|
if (isNewJoin)
|
||||||
{
|
{
|
||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
|
// Fetch presence data so clients can update their lists incrementally
|
||||||
|
UserPresenceDto? presence = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var presenceScope = _scopeFactory.CreateScope();
|
||||||
|
var presenceDb = presenceScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var user = await presenceDb.Users.FindAsync(userId);
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
presence = new UserPresenceDto(
|
||||||
|
user.Username, user.DisplayName, user.NicknameColor,
|
||||||
|
user.Status, user.StatusMessage, user.Role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to fetch presence for {User} on join", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't broadcast join for invisible users — they still get history but stay hidden
|
||||||
|
if (presence is null || presence.Status != UserStatus.Invisible)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +296,7 @@ public class ChatService : IChatService
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await db.Users
|
return await db.Users
|
||||||
.Where(u => onlineUsernames.Contains(u.Username))
|
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
|
||||||
.Select(u => new UserPresenceDto(
|
.Select(u => new UserPresenceDto(
|
||||||
u.Username,
|
u.Username,
|
||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
|
|||||||
@@ -108,9 +108,47 @@ public partial class LinkEmbedService
|
|||||||
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||||
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||||
|
|
||||||
return new EmbedDto(siteName, title, description, null, url);
|
// Extract theme-color meta tag for embed border color
|
||||||
|
var themeColor = ParseThemeColor(html);
|
||||||
|
|
||||||
|
return new EmbedDto(siteName, title, description, null, url, themeColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ParseThemeColor(string html)
|
||||||
|
{
|
||||||
|
// ThemeColorRegex: group 3 = color value
|
||||||
|
var match = ThemeColorRegex().Match(html);
|
||||||
|
var color = match.Success ? match.Groups[3].Value.Trim() : null;
|
||||||
|
|
||||||
|
if (color is null)
|
||||||
|
{
|
||||||
|
// ThemeColorReversedRegex: group 2 = color value
|
||||||
|
match = ThemeColorReversedRegex().Match(html);
|
||||||
|
color = match.Success ? match.Groups[2].Value.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (color.Length == 4 && color[0] == '#'
|
||||||
|
&& IsHexDigit(color[1]) && IsHexDigit(color[2]) && IsHexDigit(color[3]))
|
||||||
|
{
|
||||||
|
// Expand #RGB to #RRGGBB
|
||||||
|
return $"#{color[1]}{color[1]}{color[2]}{color[2]}{color[3]}{color[3]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length == 7 && color[0] == '#'
|
||||||
|
&& color[1..].All(IsHexDigit))
|
||||||
|
{
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHexDigit(char c) =>
|
||||||
|
c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F');
|
||||||
|
|
||||||
private static List<string> ExtractUrls(string content)
|
private static List<string> ExtractUrls(string content)
|
||||||
{
|
{
|
||||||
var urls = new List<string>();
|
var urls = new List<string>();
|
||||||
@@ -213,4 +251,14 @@ public partial class LinkEmbedService
|
|||||||
|
|
||||||
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
private static partial Regex TitleTagRegex();
|
private static partial Regex TitleTagRegex();
|
||||||
|
|
||||||
|
// <meta name="theme-color" content="#hex">
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?name\s*=\s*([""'])theme-color\1[^>]*?content\s*=\s*([""'])(.*?)\2[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex ThemeColorRegex();
|
||||||
|
|
||||||
|
// <meta content="#hex" name="theme-color">
|
||||||
|
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?name\s*=\s*([""'])theme-color\3[^>]*/?>",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
|
private static partial Regex ThemeColorReversedRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically unmutes users whose timed mute has expired.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MuteExpirationService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<MuteExpirationService> _logger;
|
||||||
|
|
||||||
|
public MuteExpirationService(IServiceScopeFactory scopeFactory, ILogger<MuteExpirationService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await UnmuteExpiredUsersAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error checking mute expirations");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(CheckInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UnmuteExpiredUsersAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expired = await db.Users
|
||||||
|
.Where(u => u.IsMuted && u.MutedUntil.HasValue && u.MutedUntil.Value <= now)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (expired.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var user in expired)
|
||||||
|
{
|
||||||
|
user.IsMuted = false;
|
||||||
|
user.MutedUntil = null;
|
||||||
|
_logger.LogInformation("Auto-unmuted user {Username} (timed mute expired)", user.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
_connections[connectionId] = (userId, username);
|
_connections[connectionId] = (userId, username);
|
||||||
|
|
||||||
|
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
||||||
|
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (!_userConnections.TryGetValue(username, out var connections))
|
if (!_userConnections.TryGetValue(username, out var connections))
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
|
|
||||||
var currentCount = _presenceTracker.GetOnlineUserCount();
|
var currentCount = _presenceTracker.GetOnlineUserCount();
|
||||||
|
|
||||||
|
if (currentCount == _lastReportedUserCount)
|
||||||
|
continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
|
||||||
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
|
return HubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username, presence);
|
||||||
|
|
||||||
return HubContext.Clients.Group(channelName).UserJoined(channelName, username);
|
return HubContext.Clients.Group(channelName).UserJoined(channelName, username, presence);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendUserLeftAsync(string channelName, string username)
|
public Task SendUserLeftAsync(string channelName, string username)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public class IrcBroadcasterTests
|
|||||||
{
|
{
|
||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
await _broadcaster.SendUserJoinedAsync("general", "alice");
|
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
|
||||||
|
|
||||||
var output = bobStream.GetOutputLines();
|
var output = bobStream.GetOutputLines();
|
||||||
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
|
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
|
||||||
@@ -144,7 +144,7 @@ public class IrcBroadcasterTests
|
|||||||
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
|
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
|
||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
|
await _broadcaster.SendUserJoinedAsync("general", "alice", null, conn.ConnectionId);
|
||||||
|
|
||||||
// Excluded connection should not get the message
|
// Excluded connection should not get the message
|
||||||
Assert.Empty(excludedStream.GetOutputLines());
|
Assert.Empty(excludedStream.GetOutputLines());
|
||||||
|
|||||||
Reference in New Issue
Block a user