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:
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user