mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 07:36:03 +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:
@@ -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 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 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 EmbedTitleAttr = new(Color.White, 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);
|
||||
|
||||
/// <summary>
|
||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||
/// Non-mention text uses the provided default color.
|
||||
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||
/// Non-special text uses the provided default color.
|
||||
/// </summary>
|
||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||
{
|
||||
@@ -38,12 +39,40 @@ public static partial class ChatColors
|
||||
lastIndex = match.Index + match.Length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.Length)
|
||||
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
||||
// Second pass: highlight #channels in non-mention segments
|
||||
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;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"@[\w-]+")]
|
||||
// @mention — not preceded by a word char (avoids emails)
|
||||
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
|
||||
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? AttachmentFileName { 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>
|
||||
public int ContinuationIndent { get; set; }
|
||||
|
||||
@@ -118,13 +119,14 @@ public partial class ChatLine
|
||||
if (results.Count == 0)
|
||||
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)
|
||||
{
|
||||
wrapped.AttachmentUrl = AttachmentUrl;
|
||||
wrapped.AttachmentFileName = AttachmentFileName;
|
||||
wrapped.Type = Type;
|
||||
wrapped.MessageId = MessageId;
|
||||
wrapped.SenderUsername = SenderUsername;
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -272,7 +272,10 @@ public sealed class ChatMessageManager
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
line.MessageId = message.Id;
|
||||
line.SenderUsername = message.SenderUsername;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||
{
|
||||
@@ -329,18 +332,20 @@ public sealed class ChatMessageManager
|
||||
int textWidth = chatWidth - indentCols - borderCols;
|
||||
if (textWidth < 20) textWidth = 20;
|
||||
|
||||
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||
|
||||
void AddTextLine(string text, Attribute? color)
|
||||
{
|
||||
lines.Add(new ChatLine(
|
||||
[
|
||||
new ChatSegment(indent, null),
|
||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||
new ChatSegment(border, borderAttr),
|
||||
new ChatSegment(text, color)
|
||||
]));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||
AddTextLine(embed.SiteName, borderAttr);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
|
||||
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
|
||||
/// </summary>
|
||||
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 int Count => _users.Count;
|
||||
public int MaxItemLength { get; private 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.AddRange(users);
|
||||
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
|
||||
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 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)
|
||||
{
|
||||
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);
|
||||
|
||||
// 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.Themes;
|
||||
using EchoHub.Client.UI.Chat;
|
||||
@@ -19,7 +20,7 @@ namespace EchoHub.Client.UI;
|
||||
/// <summary>
|
||||
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||
/// </summary>
|
||||
public sealed class MainWindow : Runnable
|
||||
public sealed partial class MainWindow : Runnable
|
||||
{
|
||||
private readonly IApplication _app;
|
||||
private readonly ListView _channelList;
|
||||
@@ -140,6 +141,16 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
_app = app;
|
||||
@@ -250,6 +261,7 @@ public sealed class MainWindow : Runnable
|
||||
};
|
||||
_usersListSource = new UserListSource();
|
||||
_usersList.Source = _usersListSource;
|
||||
_usersList.Accepting += OnUsersListAccepting;
|
||||
_usersFrame.Add(_usersList);
|
||||
Add(_usersFrame);
|
||||
|
||||
@@ -410,17 +422,66 @@ public sealed class MainWindow : Runnable
|
||||
return;
|
||||
|
||||
var line = source.GetLine(index.Value);
|
||||
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
|
||||
return;
|
||||
if (line is null) 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -862,14 +923,14 @@ public sealed class MainWindow : Runnable
|
||||
var name = u.DisplayName ?? u.Username;
|
||||
var roleTag = u.Role switch
|
||||
{
|
||||
ServerRole.Owner => "\u2605", // ★
|
||||
ServerRole.Admin => "\u2666", // ♦
|
||||
ServerRole.Mod => "\u2740", // ❀
|
||||
ServerRole.Owner => "\u2605 ", // ★
|
||||
ServerRole.Admin => "\u2666 ", // ♦
|
||||
ServerRole.Mod => "\u2740 ", // ❀
|
||||
_ => ""
|
||||
};
|
||||
var text = $"{statusIcon} {roleTag}{name}";
|
||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||
return (text, nameColor);
|
||||
return (text, nameColor, u.Username);
|
||||
}).ToList();
|
||||
|
||||
_usersListSource.Update(displayItems);
|
||||
@@ -877,4 +938,11 @@ public sealed class MainWindow : Runnable
|
||||
_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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user