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:
HueByte
2026-02-24 20:56:40 +01:00
parent 8647b05c12
commit 0c16f44db6
21 changed files with 451 additions and 49 deletions
+1
View File
@@ -109,6 +109,7 @@ while (true)
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>();
// ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
+26 -2
View File
@@ -107,7 +107,31 @@ public class ChatService : IChatService
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);
}
@@ -272,7 +296,7 @@ public class ChatService : IChatService
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users
.Where(u => onlineUsernames.Contains(u.Username))
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
.Select(u => new UserPresenceDto(
u.Username,
u.DisplayName,
@@ -108,9 +108,47 @@ public partial class LinkEmbedService
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : 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)
{
var urls = new List<string>();
@@ -213,4 +251,14 @@ public partial class LinkEmbedService
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
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);
// 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)
{
if (!_userConnections.TryGetValue(username, out var connections))
@@ -170,6 +170,9 @@ public sealed class ServerDirectoryService : BackgroundService
var currentCount = _presenceTracker.GetOnlineUserCount();
if (currentCount == _lastReportedUserCount)
continue;
try
{
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
@@ -23,12 +23,12 @@ public class SignalRBroadcaster : IChatBroadcaster
public Task SendMessageToChannelAsync(string channelName, MessageDto 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-"))
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)