mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat(chat): enhance chat UI with new features and visual improvements
- Added new attributes for chat colors including rail, date rules, and unread markers. - Enhanced ChatLine class to support colored segments for continuation lines and horizontal separator rules. - Introduced a WelcomeBanner class to display a splash screen when no channel is selected. - Implemented NickColorHelper for deterministic per-nick colors, ensuring consistent user color representation. - Updated ChannelListSource to highlight channels with unread messages and mentions. - Improved MainWindow to include a spinner for transitional connection states and display activity in the status bar. - Added unit tests for NickColorHelper to ensure stable and predictable color indexing.
This commit is contained in:
@@ -21,6 +21,15 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
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>The dim vertical rail (│) separating the nick column from message text.</summary>
|
||||||
|
public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None);
|
||||||
|
|
||||||
|
/// <summary>Horizontal date-separator rules (── Wed, Jul 16 ──…).</summary>
|
||||||
|
public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
|
||||||
|
/// <summary>The irssi-style "new messages" unread marker rule.</summary>
|
||||||
|
public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
/// Split text around @mentions and #channels, giving each the appropriate accent color.
|
||||||
/// Non-special text uses the provided default color.
|
/// Non-special text uses the provided default color.
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ public partial class ChatLine
|
|||||||
/// <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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colored segments to prepend on continuation lines instead of plain spaces
|
||||||
|
/// (e.g. the nick-column rail " │ "). When set, takes
|
||||||
|
/// precedence over <see cref="ContinuationIndent"/>.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatSegment>? ContinuationPrefixSegments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, this line is a horizontal separator rule (date change, unread marker).
|
||||||
|
/// The view regenerates it to the current viewport width instead of word-wrapping.
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleLabel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Color for a rule line; null falls back to <see cref="ChatColors.DateRuleAttr"/>.</summary>
|
||||||
|
public Attribute? RuleAttr { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True for the "new messages" unread-marker rule so it can be removed on channel switch.</summary>
|
||||||
|
public bool IsUnreadMarker { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
@@ -43,9 +62,14 @@ public partial class ChatLine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
|
||||||
{
|
{
|
||||||
if (width <= 0 || TextLength <= width)
|
// Rules are regenerated to viewport width by the view; never word-wrap them.
|
||||||
|
if (RuleLabel is not null || width <= 0 || TextLength <= width)
|
||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
|
var prefixSegments = ContinuationPrefixSegments;
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns());
|
||||||
|
|
||||||
var tokens = new List<(string grapheme, Attribute? color)>();
|
var tokens = new List<(string grapheme, Attribute? color)>();
|
||||||
foreach (var segment in Segments)
|
foreach (var segment in Segments)
|
||||||
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
@@ -91,8 +115,13 @@ public partial class ChatLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
if (!firstLine && continuationIndent > 0)
|
if (!firstLine)
|
||||||
|
{
|
||||||
|
if (prefixSegments is not null)
|
||||||
|
segments.AddRange(prefixSegments);
|
||||||
|
else if (continuationIndent > 0)
|
||||||
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild segments by grouping consecutive same-color tokens.
|
// Rebuild segments by grouping consecutive same-color tokens.
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -120,6 +149,7 @@ public partial class ChatLine
|
|||||||
return [this];
|
return [this];
|
||||||
|
|
||||||
// Propagate metadata to all wrapped lines so they remain clickable
|
// Propagate metadata to all wrapped lines so they remain clickable
|
||||||
|
// and keep the mention highlight across continuation lines
|
||||||
foreach (var wrapped in results)
|
foreach (var wrapped in results)
|
||||||
{
|
{
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
@@ -127,6 +157,7 @@ public partial class ChatLine
|
|||||||
wrapped.AttachmentKind = AttachmentKind;
|
wrapped.AttachmentKind = AttachmentKind;
|
||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
|
wrapped.IsMention = IsMention;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -15,8 +16,18 @@ namespace EchoHub.Client.UI.Chat;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatMessageManager
|
public sealed class ChatMessageManager
|
||||||
{
|
{
|
||||||
|
/// <summary>Columns reserved for the right-aligned nick column (WeeChat-style).</summary>
|
||||||
|
public const int NickColWidth = 12;
|
||||||
|
|
||||||
|
/// <summary>Columns before message text starts: "HH:mm " + nick column + " │ ".</summary>
|
||||||
|
public const int ContentIndentCols = 6 + NickColWidth + 3;
|
||||||
|
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
private readonly Dictionary<string, DateTime> _channelLastDate = [];
|
||||||
|
private readonly HashSet<string> _markedChannels = [];
|
||||||
|
private readonly Dictionary<string, Guid> _markerAnchor = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
|
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
@@ -33,7 +44,16 @@ public sealed class ChatMessageManager
|
|||||||
public string CurrentChannel
|
public string CurrentChannel
|
||||||
{
|
{
|
||||||
get => _currentChannel;
|
get => _currentChannel;
|
||||||
set => _currentChannel = value;
|
set
|
||||||
|
{
|
||||||
|
if (_currentChannel == value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Leaving a channel consumes its "new messages" marker so the next
|
||||||
|
// unread burst gets a fresh one (irssi behavior).
|
||||||
|
RemoveUnreadMarker(_currentChannel);
|
||||||
|
_currentChannel = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CurrentUser => _currentUser;
|
public string CurrentUser => _currentUser;
|
||||||
@@ -57,10 +77,14 @@ public sealed class ChatMessageManager
|
|||||||
public void ClearUnread(string channelName)
|
public void ClearUnread(string channelName)
|
||||||
{
|
{
|
||||||
_channelUnread[channelName] = 0;
|
_channelUnread[channelName] = 0;
|
||||||
|
_mentionChannels.Remove(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
/// <summary>Channels with an unread @mention of the current user (cleared by <see cref="ClearUnread"/>).</summary>
|
||||||
|
public IReadOnlySet<string> MentionChannels => _mentionChannels;
|
||||||
|
|
||||||
// ── Mutations ────────────────────────────────────────────────────
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,19 +99,36 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[message.ChannelName] = messages;
|
_channelMessages[message.ChannelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Day boundary → horizontal date rule
|
||||||
|
var msgDate = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (!_channelLastDate.TryGetValue(message.ChannelName, out var lastDate) || lastDate != msgDate)
|
||||||
|
{
|
||||||
|
messages.Add(DateRule(msgDate));
|
||||||
|
_channelLastDate[message.ChannelName] = msgDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCurrent = message.ChannelName == _currentChannel;
|
||||||
|
|
||||||
|
// First unread message in an inactive channel → "new messages" marker,
|
||||||
|
// anchored to this message so a history reload can re-place it
|
||||||
|
if (!isCurrent && _markedChannels.Add(message.ChannelName))
|
||||||
|
{
|
||||||
|
messages.Add(UnreadMarkerRule());
|
||||||
|
_markerAnchor[message.ChannelName] = message.Id;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
messages.Add(line);
|
messages.Add(line);
|
||||||
|
|
||||||
if (message.ChannelName == _currentChannel)
|
if (!isCurrent)
|
||||||
{
|
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
_channelUnread[message.ChannelName] = count + 1;
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
MessagesChanged?.Invoke(message.ChannelName);
|
if (lines.Any(l => l.IsMention))
|
||||||
|
_mentionChannels.Add(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,24 +142,20 @@ public sealed class ChatMessageManager
|
|||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
var time = FormatDateTime(DateTimeOffset.Now);
|
var time = FormatTime(DateTimeOffset.Now);
|
||||||
var textLines = text.Split('\n');
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
messages.Add(new ChatLine(
|
var header = SystemHeaderSegments(time);
|
||||||
[
|
header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
|
|
||||||
var indent = new string(' ', $"[{time}] ** ".Length);
|
|
||||||
for (int i = 1; i < textLines.Length; i++)
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
{
|
{
|
||||||
var line = textLines[i].TrimEnd('\r');
|
var line = textLines[i].TrimEnd('\r');
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
messages.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new(line, ChatColors.SystemAttr));
|
||||||
new($"{indent}{line}", ChatColors.SystemAttr)
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -130,19 +167,16 @@ public sealed class ChatMessageManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddStatusMessage(string channelName, string username, string status)
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
{
|
{
|
||||||
var time = FormatDateTime(DateTimeOffset.Now);
|
var time = FormatTime(DateTimeOffset.Now);
|
||||||
var segments = new List<ChatSegment>
|
var segments = SystemHeaderSegments(time);
|
||||||
{
|
segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr));
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages = [];
|
messages = [];
|
||||||
_channelMessages[channelName] = messages;
|
_channelMessages[channelName] = messages;
|
||||||
}
|
}
|
||||||
messages.Add(new ChatLine(segments));
|
messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
@@ -169,6 +203,9 @@ public sealed class ChatMessageManager
|
|||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
{
|
{
|
||||||
messages.Clear();
|
messages.Clear();
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
}
|
}
|
||||||
@@ -179,9 +216,35 @@ public sealed class ChatMessageManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||||
{
|
{
|
||||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
var formatted = FormatWithDateRules(messages, out var lastDate);
|
||||||
|
|
||||||
|
// Re-place the "new messages" marker at its anchor — channel selection
|
||||||
|
// reloads history, which would otherwise wipe the marker right when the
|
||||||
|
// user switches in to read the unread backlog.
|
||||||
|
if (_markedChannels.Contains(channelName))
|
||||||
|
{
|
||||||
|
var anchorIdx = _markerAnchor.TryGetValue(channelName, out var anchorId)
|
||||||
|
? formatted.FindIndex(l => l.MessageId == anchorId)
|
||||||
|
: -1;
|
||||||
|
if (anchorIdx >= 0)
|
||||||
|
{
|
||||||
|
formatted.Insert(anchorIdx, UnreadMarkerRule());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Anchor fell outside the fetched history window — drop the marker
|
||||||
|
_markedChannels.Remove(channelName);
|
||||||
|
_markerAnchor.Remove(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_channelMessages[channelName] = formatted;
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (lastDate is { } date)
|
||||||
|
_channelLastDate[channelName] = date;
|
||||||
|
else
|
||||||
|
_channelLastDate.Remove(channelName);
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
MessagesChanged?.Invoke(channelName);
|
MessagesChanged?.Invoke(channelName);
|
||||||
}
|
}
|
||||||
@@ -200,14 +263,23 @@ public sealed class ChatMessageManager
|
|||||||
.Select(l => l.MessageId!.Value)
|
.Select(l => l.MessageId!.Value)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
var newLines = olderMessages
|
var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList();
|
||||||
.Where(m => !existingIds.Contains(m.Id))
|
var newLines = FormatWithDateRules(fresh, out var lastBatchDate);
|
||||||
.SelectMany(FormatMessage)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (newLines.Count == 0)
|
if (newLines.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// The buffer's leading date rule is redundant when the prepended batch
|
||||||
|
// ends on the same day — the batch already carries that day's rule.
|
||||||
|
if (lastBatchDate is { } batchDate
|
||||||
|
&& existing.Count > 0
|
||||||
|
&& existing[0].RuleLabel is { } label
|
||||||
|
&& !existing[0].IsUnreadMarker
|
||||||
|
&& label == DateRuleLabel(batchDate))
|
||||||
|
{
|
||||||
|
existing.RemoveAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
existing.InsertRange(0, newLines);
|
existing.InsertRange(0, newLines);
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _currentChannel)
|
||||||
@@ -226,6 +298,10 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
_channelMessages.Clear();
|
_channelMessages.Clear();
|
||||||
_channelUnread.Clear();
|
_channelUnread.Clear();
|
||||||
|
_channelLastDate.Clear();
|
||||||
|
_markedChannels.Clear();
|
||||||
|
_markerAnchor.Clear();
|
||||||
|
_mentionChannels.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
}
|
}
|
||||||
@@ -234,12 +310,9 @@ public sealed class ChatMessageManager
|
|||||||
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = FormatDateTime(message.SentAt);
|
var time = FormatTime(message.SentAt);
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
|
||||||
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
?? NickColorHelper.GetAttribute(message.SenderUsername);
|
||||||
|
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
|
||||||
var pad = new string(' ', 7);
|
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||||
@@ -250,9 +323,17 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
var contentLines = displayContent.Split('\n');
|
var contentLines = displayContent.Split('\n');
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}"));
|
|
||||||
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
|
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}")));
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r')));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -262,13 +343,15 @@ public sealed class ChatMessageManager
|
|||||||
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
|
||||||
_ => $"[{attachments.Count} attachments]",
|
_ => $"[{attachments.Count} attachments]",
|
||||||
};
|
};
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, summary));
|
var header = HeaderSegments(time, message.SenderUsername, senderColor);
|
||||||
|
header.Add(new(summary, null));
|
||||||
|
lines.Add(new ChatLine(header));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var l in lines)
|
foreach (var l in lines)
|
||||||
l.ContinuationIndent = indent.Length;
|
l.ContinuationPrefixSegments = RailPrefix();
|
||||||
|
|
||||||
// One block per attachment
|
// One block per attachment — every block hangs off the nick-column rail
|
||||||
foreach (var attachment in attachments)
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
switch (attachment.Kind)
|
||||||
@@ -279,24 +362,27 @@ public sealed class ChatMessageManager
|
|||||||
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
|
||||||
{
|
{
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
lines.Add(ChatLine.HasColorTags(trimmed)
|
var segments = RailPrefix();
|
||||||
? ChatLine.FromColoredText(pad + trimmed)
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
: new ChatLine($"{pad}{trimmed}"));
|
segments.AddRange(ChatLine.FromColoredText(trimmed).Segments);
|
||||||
|
else
|
||||||
|
segments.Add(new(trimmed, null));
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.FileAttr, attachment));
|
ChatColors.FileAttr, attachment));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Core.Models.AttachmentKind.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.AudioAttr, attachment));
|
ChatColors.AudioAttr, attachment));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
lines.Add(AttachmentActionLine(pad,
|
lines.Add(AttachmentActionLine(
|
||||||
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
|
||||||
ChatColors.FileAttr, attachment));
|
ChatColors.FileAttr, attachment));
|
||||||
break;
|
break;
|
||||||
@@ -308,7 +394,7 @@ public sealed class ChatMessageManager
|
|||||||
{
|
{
|
||||||
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
lines.AddRange(FormatEmbed(embed, chatWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -334,71 +420,142 @@ public sealed class ChatMessageManager
|
|||||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
/// route activation (play audio, download file, save original image).
|
/// route activation (play audio, download file, save original image).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment)
|
private static ChatLine AttachmentActionLine(string text, Attribute color, AttachmentDto attachment)
|
||||||
{
|
{
|
||||||
var line = new ChatLine(new List<ChatSegment>
|
var segments = RailPrefix();
|
||||||
|
segments.Add(new(text, color));
|
||||||
|
return new ChatLine(segments)
|
||||||
{
|
{
|
||||||
new(pad, null),
|
AttachmentUrl = attachment.Url,
|
||||||
new(text, color),
|
AttachmentFileName = attachment.FileName,
|
||||||
});
|
AttachmentKind = attachment.Kind,
|
||||||
line.AttachmentUrl = attachment.Url;
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
line.AttachmentFileName = attachment.FileName;
|
|
||||||
line.AttachmentKind = attachment.Kind;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
new(suffix, null)
|
|
||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
/// <summary>
|
||||||
|
/// Leading segments of a message header line: dim "HH:mm ", the right-aligned
|
||||||
|
/// nick column, and the " │ " rail. Message text follows at <see cref="ContentIndentCols"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> HeaderSegments(string time, string nick, Attribute? nickColor) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick(nick), nickColor),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Header variant for system/status lines: "--" in the nick column.</summary>
|
||||||
|
private static List<ChatSegment> SystemHeaderSegments(string time) =>
|
||||||
|
[
|
||||||
|
new($"{time} ", ChatColors.TimestampAttr),
|
||||||
|
new(PadNick("--"), ChatColors.TimestampAttr),
|
||||||
|
new(" │ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indent segments aligning continuation/attachment/embed lines under the message
|
||||||
|
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatSegment> RailPrefix() =>
|
||||||
|
[
|
||||||
|
new(new string(' ', 6 + NickColWidth + 1), null),
|
||||||
|
new("│ ", ChatColors.RailAttr),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-aligns a nick into the fixed nick column, truncating over-long nicks
|
||||||
|
/// with an ellipsis. Grapheme/column aware.
|
||||||
|
/// </summary>
|
||||||
|
internal static string PadNick(string nick)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
var cols = nick.GetColumns();
|
||||||
|
if (cols > NickColWidth)
|
||||||
{
|
{
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
var sb = new StringBuilder();
|
||||||
new(senderName, senderColor),
|
int used = 0;
|
||||||
new(suffix, suffixColor)
|
foreach (var g in GraphemeHelper.GetGraphemes(nick))
|
||||||
|
{
|
||||||
|
var gCols = Math.Max(g.GetColumns(), 1);
|
||||||
|
if (used + gCols > NickColWidth - 1) break;
|
||||||
|
sb.Append(g);
|
||||||
|
used += gCols;
|
||||||
|
}
|
||||||
|
sb.Append('…');
|
||||||
|
nick = sb.ToString();
|
||||||
|
cols = used + 1;
|
||||||
|
}
|
||||||
|
return new string(' ', NickColWidth - cols) + nick;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
|
||||||
|
|
||||||
|
private static ChatLine DateRule(DateTime date)
|
||||||
|
{
|
||||||
|
var label = DateRuleLabel(date);
|
||||||
|
return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
|
||||||
|
{
|
||||||
|
RuleLabel = label,
|
||||||
|
RuleAttr = ChatColors.DateRuleAttr,
|
||||||
};
|
};
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
private static ChatLine UnreadMarkerRule() =>
|
||||||
|
new([new("── new messages ──", ChatColors.UnreadMarkerAttr)])
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>
|
RuleLabel = "new messages",
|
||||||
{
|
RuleAttr = ChatColors.UnreadMarkerAttr,
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
IsUnreadMarker = true,
|
||||||
new(senderName, senderColor),
|
|
||||||
};
|
};
|
||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
|
||||||
return new ChatLine(segments);
|
private void RemoveUnreadMarker(string channel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(channel) || !_markedChannels.Remove(channel))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_markerAnchor.Remove(channel);
|
||||||
|
if (_channelMessages.TryGetValue(channel, out var messages))
|
||||||
|
messages.RemoveAll(l => l.IsUnreadMarker);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
/// <summary>
|
||||||
|
/// Formats a chronological batch of messages, inserting a date rule before the
|
||||||
|
/// first message and at every day boundary. Outputs the batch's last local date.
|
||||||
|
/// </summary>
|
||||||
|
private List<ChatLine> FormatWithDateRules(List<MessageDto> messages, out DateTime? lastDate)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
lastDate = null;
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
var date = message.SentAt.ToLocalTime().Date;
|
||||||
|
if (lastDate != date)
|
||||||
|
{
|
||||||
|
lines.Add(DateRule(date));
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
lines.AddRange(FormatMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, int chatWidth)
|
||||||
{
|
{
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
const string border = "\u258f "; // ▏ + space
|
const string border = "\u258f "; // ▏ + space
|
||||||
const int borderCols = 2;
|
const int borderCols = 2;
|
||||||
int indentCols = indent.GetColumns();
|
int textWidth = chatWidth - ContentIndentCols - borderCols;
|
||||||
int textWidth = chatWidth - indentCols - borderCols;
|
|
||||||
if (textWidth < 20) textWidth = 20;
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
|
||||||
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
void AddTextLine(string text, Attribute? color)
|
||||||
{
|
{
|
||||||
lines.Add(new ChatLine(
|
var segments = RailPrefix();
|
||||||
[
|
segments.Add(new ChatSegment(border, borderAttr));
|
||||||
new ChatSegment(indent, null),
|
segments.Add(new ChatSegment(text, color));
|
||||||
new ChatSegment(border, borderAttr),
|
lines.Add(new ChatLine(segments));
|
||||||
new ChatSegment(text, color)
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
@@ -449,20 +606,11 @@ public sealed class ChatMessageManager
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatDateTime(DateTimeOffset timestamp)
|
// Timestamps are compact HH:mm — the calendar day is carried by date rules,
|
||||||
{
|
// inserted at every local-day boundary. Convert to local first so a message
|
||||||
// Server timestamps arrive in UTC; convert to local before deciding the calendar day,
|
// near midnight lands under the right date rule.
|
||||||
// otherwise a "today" message near midnight is misclassified against the local date.
|
private static string FormatTime(DateTimeOffset timestamp) =>
|
||||||
var local = timestamp.ToLocalTime();
|
timestamp.ToLocalTime().ToString("HH:mm");
|
||||||
|
|
||||||
// Today's messages show a compact date + short time; older messages fall back to the
|
|
||||||
// culture's general short date/time. Both always include the date so new messages
|
|
||||||
// are never left date-less.
|
|
||||||
if (local.Date == DateTimeOffset.Now.Date)
|
|
||||||
return $"{local:d} {local:t}";
|
|
||||||
|
|
||||||
return local.ToString("g");
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string FormatFileSize(long? bytes)
|
internal static string FormatFileSize(long? bytes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The MOTD-style splash rendered into the chat pane when no channel is selected —
|
||||||
|
/// a gold-gradient ASCII logo with version and key hints, in the spirit of classic
|
||||||
|
/// IRC client greetings.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WelcomeBanner
|
||||||
|
{
|
||||||
|
// "ECHOHUB" in FIGlet ANSI-Shadow (58 columns)
|
||||||
|
private static readonly string[] BigLogo =
|
||||||
|
[
|
||||||
|
"███████╗ ██████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ ",
|
||||||
|
"██╔════╝██╔════╝██║ ██║██╔═══██╗██║ ██║██║ ██║██╔══██╗",
|
||||||
|
"█████╗ ██║ ███████║██║ ██║███████║██║ ██║██████╔╝",
|
||||||
|
"██╔══╝ ██║ ██╔══██║██║ ██║██╔══██║██║ ██║██╔══██╗",
|
||||||
|
"███████╗╚██████╗██║ ██║╚██████╔╝██║ ██║╚██████╔╝██████╔╝",
|
||||||
|
"╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compact box-drawing fallback for narrow panes (21 columns)
|
||||||
|
private static readonly string[] SmallLogo =
|
||||||
|
[
|
||||||
|
"┌─┐┌─┐┬ ┬┌─┐┬ ┬┬ ┬┌┐ ",
|
||||||
|
"├┤ │ ├─┤│ │├─┤│ │├┴┐",
|
||||||
|
"└─┘└─┘┴ ┴└─┘┴ ┴└─┘└─┘",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical gold gradient, bright at the top fading to bronze — matches the
|
||||||
|
// EchoHub brand color used in the status bar.
|
||||||
|
private static readonly Color[] Gradient =
|
||||||
|
[
|
||||||
|
new(255, 215, 105),
|
||||||
|
new(245, 199, 89),
|
||||||
|
new(232, 183, 74),
|
||||||
|
new(216, 165, 60),
|
||||||
|
new(198, 146, 48),
|
||||||
|
new(178, 128, 38),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly Attribute HintKeyAttr = new(new Color(140, 170, 200), Color.None);
|
||||||
|
private static readonly Attribute HintTextAttr = new(new Color(120, 120, 120), Color.None);
|
||||||
|
private static readonly Attribute TaglineAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
|
||||||
|
private static readonly (string Key, string Hint)[] Hints =
|
||||||
|
[
|
||||||
|
("Server → Connect", "join a server"),
|
||||||
|
("Ctrl+K ", "search channels & users"),
|
||||||
|
("F2 ", "toggle the users panel"),
|
||||||
|
("/help ", "all commands"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds banner lines centered for the given viewport width.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatLine> Build(int width, string version)
|
||||||
|
{
|
||||||
|
var logo = width >= BigLogo[0].GetColumns() + 2 ? BigLogo : SmallLogo;
|
||||||
|
int logoWidth = logo[0].GetColumns();
|
||||||
|
var pad = new string(' ', Math.Max((width - logoWidth) / 2, 0));
|
||||||
|
|
||||||
|
var lines = new List<ChatLine> { new(""), new("") };
|
||||||
|
|
||||||
|
for (int i = 0; i < logo.Length; i++)
|
||||||
|
{
|
||||||
|
// Scale the gradient across however many rows the chosen logo has
|
||||||
|
var color = Gradient[Math.Min(i * Gradient.Length / logo.Length, Gradient.Length - 1)];
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(pad, null),
|
||||||
|
new ChatSegment(logo[i], new Attribute(color, Color.None)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
var tagline = $"v{version} — terminal chat with that old IRC soul";
|
||||||
|
lines.Add(Centered(tagline, width, TaglineAttr));
|
||||||
|
lines.Add(new ChatLine(""));
|
||||||
|
|
||||||
|
int hintWidth = Hints.Max(h => h.Key.Length + 2 + h.Hint.Length);
|
||||||
|
var hintPad = new string(' ', Math.Max((width - hintWidth) / 2, 0));
|
||||||
|
foreach (var (key, hint) in Hints)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine([
|
||||||
|
new ChatSegment(hintPad, null),
|
||||||
|
new ChatSegment(key, HintKeyAttr),
|
||||||
|
new ChatSegment(" " + hint, HintTextAttr),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine Centered(string text, int width, Attribute attr)
|
||||||
|
{
|
||||||
|
var pad = new string(' ', Math.Max((width - text.GetColumns()) / 2, 0));
|
||||||
|
return new ChatLine([new ChatSegment(pad, null), new ChatSegment(text, attr)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic per-nick colors for users who haven't picked a nickname color.
|
||||||
|
/// The same nick always maps to the same palette entry (classic IRC client behavior),
|
||||||
|
/// so a busy channel stays scannable without any configuration.
|
||||||
|
/// </summary>
|
||||||
|
public static class NickColorHelper
|
||||||
|
{
|
||||||
|
// Medium-saturation truecolor values chosen to stay readable on both dark and
|
||||||
|
// light backgrounds. Order matters: changing it re-colors everyone.
|
||||||
|
private static readonly Attribute[] Palette =
|
||||||
|
[
|
||||||
|
new(new Color(224, 108, 117), Color.None), // soft red
|
||||||
|
new(new Color(152, 195, 121), Color.None), // green
|
||||||
|
new(new Color(229, 192, 123), Color.None), // sand
|
||||||
|
new(new Color(97, 175, 239), Color.None), // blue
|
||||||
|
new(new Color(198, 120, 221), Color.None), // magenta
|
||||||
|
new(new Color(86, 182, 194), Color.None), // teal
|
||||||
|
new(new Color(255, 160, 122), Color.None), // salmon
|
||||||
|
new(new Color(130, 170, 255), Color.None), // periwinkle
|
||||||
|
new(new Color(195, 232, 141), Color.None), // lime
|
||||||
|
new(new Color(137, 221, 255), Color.None), // sky
|
||||||
|
new(new Color(255, 203, 107), Color.None), // amber
|
||||||
|
new(new Color(240, 130, 170), Color.None), // rose
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stable palette index for a nick: case-insensitive FNV-1a over the nick,
|
||||||
|
/// reduced modulo <paramref name="paletteSize"/>. Pure function (no Terminal.Gui
|
||||||
|
/// types) so it is unit-testable without a display driver.
|
||||||
|
/// </summary>
|
||||||
|
public static int GetPaletteIndex(string nick, int paletteSize)
|
||||||
|
{
|
||||||
|
if (paletteSize <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
const uint fnvOffset = 2166136261;
|
||||||
|
const uint fnvPrime = 16777619;
|
||||||
|
|
||||||
|
uint hash = fnvOffset;
|
||||||
|
foreach (var ch in nick)
|
||||||
|
{
|
||||||
|
hash ^= char.ToLowerInvariant(ch);
|
||||||
|
hash *= fnvPrime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)(hash % (uint)paletteSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The color attribute for a nick. Used as a fallback when the user has no
|
||||||
|
/// explicit nickname color set.
|
||||||
|
/// </summary>
|
||||||
|
public static Attribute GetAttribute(string nick) =>
|
||||||
|
Palette[GetPaletteIndex(nick, Palette.Length)];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public class ChannelListSource : IListDataSource
|
|||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
private readonly HashSet<string> _protectedChannels = [];
|
private readonly HashSet<string> _protectedChannels = [];
|
||||||
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -28,9 +29,10 @@ public class ChannelListSource : IListDataSource
|
|||||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||||
IReadOnlySet<string>? protectedChannels = null)
|
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null)
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelNames.AddRange(channels);
|
_channelNames.AddRange(channels);
|
||||||
@@ -40,6 +42,9 @@ public class ChannelListSource : IListDataSource
|
|||||||
_protectedChannels.Clear();
|
_protectedChannels.Clear();
|
||||||
if (protectedChannels is not null)
|
if (protectedChannels is not null)
|
||||||
_protectedChannels.UnionWith(protectedChannels);
|
_protectedChannels.UnionWith(protectedChannels);
|
||||||
|
_mentionChannels.Clear();
|
||||||
|
if (mentionChannels is not null)
|
||||||
|
_mentionChannels.UnionWith(mentionChannels);
|
||||||
_activeChannel = activeChannel;
|
_activeChannel = activeChannel;
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
if (!SuspendCollectionChangedEvent)
|
if (!SuspendCollectionChangedEvent)
|
||||||
@@ -82,12 +87,18 @@ public class ChannelListSource : IListDataSource
|
|||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
// Mentions escalate above plain unread: the whole entry turns orange
|
||||||
|
var hasMention = _mentionChannels.Contains(name);
|
||||||
|
var nameAttr = isActive ? ActiveAttr
|
||||||
|
: hasMention ? MentionAttr
|
||||||
|
: hasUnread ? UnreadAttr
|
||||||
|
: NormalAttr;
|
||||||
|
listView.SetAttribute(Resolve(nameAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
if (hasUnread)
|
if (hasUnread)
|
||||||
{
|
{
|
||||||
listView.SetAttribute(Resolve(BadgeAttr));
|
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
||||||
Add(_statusLabel);
|
Add(_statusLabel);
|
||||||
|
|
||||||
|
// Rounded borders for a softer, modern frame look
|
||||||
|
channelsFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_chatFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_inputFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
_usersFrame.BorderStyle = LineStyle.Rounded;
|
||||||
|
|
||||||
// Apply our custom color schemes to all views
|
// Apply our custom color schemes to all views
|
||||||
ApplyColorSchemes();
|
ApplyColorSchemes();
|
||||||
|
|
||||||
@@ -1016,6 +1022,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
_connectionStatus = status;
|
_connectionStatus = status;
|
||||||
|
UpdateSpinner();
|
||||||
_statusLabel.SetNeedsDraw();
|
_statusLabel.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1030,38 @@ public sealed partial class MainWindow : Runnable
|
|||||||
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
|
||||||
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
|
||||||
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
|
||||||
|
private static readonly Attribute StatusActivityAttr = new(new Color(80, 200, 220), Color.None);
|
||||||
|
private static readonly Attribute StatusMentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
|
||||||
|
// Braille spinner shown while the connection is in a transitional state
|
||||||
|
private static readonly string[] SpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
private object? _spinnerToken;
|
||||||
|
private int _spinnerFrame;
|
||||||
|
|
||||||
|
private bool IsTransitionalStatus => _connectionStatus is not ("Connected" or "Disconnected");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the spinner timer when entering a transitional connection state
|
||||||
|
/// (Connecting, Reconnecting, …); the timer stops itself once the state settles.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateSpinner()
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus || _spinnerToken is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_spinnerToken = _app.AddTimeout(TimeSpan.FromMilliseconds(120), () =>
|
||||||
|
{
|
||||||
|
if (!IsTransitionalStatus)
|
||||||
|
{
|
||||||
|
_spinnerToken = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_spinnerFrame = (_spinnerFrame + 1) % SpinnerFrames.Length;
|
||||||
|
_statusLabel.SetNeedsDraw();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -1054,13 +1093,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Write(" EchoHub", Resolve(StatusBrandAttr));
|
Write(" EchoHub", Resolve(StatusBrandAttr));
|
||||||
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
||||||
|
|
||||||
// Connection state with color
|
// Connection state with color; transitional states get an animated spinner
|
||||||
var statusAttr = _connectionStatus switch
|
var statusAttr = _connectionStatus switch
|
||||||
{
|
{
|
||||||
"Connected" => StatusConnectedAttr,
|
"Connected" => StatusConnectedAttr,
|
||||||
"Disconnected" => StatusDisconnectedAttr,
|
"Disconnected" => StatusDisconnectedAttr,
|
||||||
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
|
||||||
};
|
};
|
||||||
|
if (IsTransitionalStatus)
|
||||||
|
Write($"{SpinnerFrames[_spinnerFrame]} ", statusAttr);
|
||||||
Write(_connectionStatus, Resolve(statusAttr));
|
Write(_connectionStatus, Resolve(statusAttr));
|
||||||
|
|
||||||
// User
|
// User
|
||||||
@@ -1079,6 +1120,28 @@ public sealed partial class MainWindow : Runnable
|
|||||||
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity segment (irssi-style): channels with unread messages,
|
||||||
|
// mention-channels highlighted in orange
|
||||||
|
var activity = _messageManager.GetUnreadCounts()
|
||||||
|
.Where(kv => kv.Value > 0)
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (activity.Count > 0)
|
||||||
|
{
|
||||||
|
const int maxShown = 4;
|
||||||
|
Write(" \u2502 Act: ", normalAttr);
|
||||||
|
var mentions = _messageManager.MentionChannels;
|
||||||
|
for (int i = 0; i < activity.Count && i < maxShown; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
Write(",", normalAttr);
|
||||||
|
Write($"#{activity[i]}", mentions.Contains(activity[i]) ? StatusMentionAttr : StatusActivityAttr);
|
||||||
|
}
|
||||||
|
if (activity.Count > maxShown)
|
||||||
|
Write($" +{activity.Count - maxShown}", normalAttr);
|
||||||
|
}
|
||||||
|
|
||||||
// Fill remaining space
|
// Fill remaining space
|
||||||
_statusLabel.SetAttribute(normalAttr);
|
_statusLabel.SetAttribute(normalAttr);
|
||||||
while (col < width)
|
while (col < width)
|
||||||
@@ -1180,9 +1243,6 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
|
||||||
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
|
||||||
if (messages is not null)
|
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
@@ -1193,13 +1253,21 @@ public sealed partial class MainWindow : Runnable
|
|||||||
else
|
else
|
||||||
width = _lastChatWidth;
|
width = _lastChatWidth;
|
||||||
|
|
||||||
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
|
if (messages is not null)
|
||||||
|
{
|
||||||
var source = new ChatListSource();
|
var source = new ChatListSource();
|
||||||
|
|
||||||
if (width > 0)
|
if (width > 0)
|
||||||
{
|
{
|
||||||
foreach (var line in messages)
|
foreach (var line in messages)
|
||||||
|
{
|
||||||
|
if (line.RuleLabel is not null)
|
||||||
|
source.Add(ExpandRule(line, width));
|
||||||
|
else
|
||||||
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
source.AddRange(messages);
|
source.AddRange(messages);
|
||||||
@@ -1211,16 +1279,36 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_messageList.Source = new ChatListSource();
|
// No channel selected — greet with the MOTD-style splash
|
||||||
|
var source = new ChatListSource();
|
||||||
|
if (width > 0)
|
||||||
|
source.AddRange(WelcomeBanner.Build(width, AppVersion));
|
||||||
|
_messageList.Source = source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regenerates a separator rule (date change / unread marker) to span the
|
||||||
|
/// current viewport width: "── label ────────…".
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ExpandRule(ChatLine line, int width)
|
||||||
|
{
|
||||||
|
var attr = line.RuleAttr ?? ChatColors.DateRuleAttr;
|
||||||
|
var label = line.RuleLabel!;
|
||||||
|
var tailLen = Math.Max(width - 4 - label.GetColumns() - 1, 2);
|
||||||
|
return new ChatLine([new ChatSegment($"── {label} {new string('─', tailLen)}", attr)])
|
||||||
|
{
|
||||||
|
IsUnreadMarker = line.IsUnreadMarker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refresh the channel list view, showing unread counts next to channel names.
|
/// Refresh the channel list view, showing unread counts next to channel names.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
||||||
|
_channelProtected, _messageManager.MentionChannels);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
@@ -1296,8 +1384,11 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var text = roleTag.Length > 0
|
var text = roleTag.Length > 0
|
||||||
? $"{statusIcon} {roleTag} {name}"
|
? $"{statusIcon} {roleTag} {name}"
|
||||||
: $"{statusIcon} {name}";
|
: $"{statusIcon} {name}";
|
||||||
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
// Fall back to the deterministic per-nick palette so user-list colors
|
||||||
return (text, nameColor, u.Username);
|
// match the same user's messages in chat.
|
||||||
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor)
|
||||||
|
?? NickColorHelper.GetAttribute(u.Username);
|
||||||
|
return (text, (Attribute?)nameColor, u.Username);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
_usersListSource.Update(displayItems);
|
_usersListSource.Update(displayItems);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the deterministic nick→palette-index hash.
|
||||||
|
/// Note: GetAttribute (Terminal.Gui Attribute) is excluded — Terminal.Gui's module
|
||||||
|
/// initializer requires a display driver unavailable in CI.
|
||||||
|
/// </summary>
|
||||||
|
public class NickColorHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_SameNick_IsStable()
|
||||||
|
{
|
||||||
|
var first = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
var second = NickColorHelper.GetPaletteIndex("alice", 12);
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
NickColorHelper.GetPaletteIndex("Alice", 12),
|
||||||
|
NickColorHelper.GetPaletteIndex("aLICE", 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("alice")]
|
||||||
|
[InlineData("bob")]
|
||||||
|
[InlineData("charlie_long_nickname")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("émile")]
|
||||||
|
public void GetPaletteIndex_AlwaysWithinRange(string nick)
|
||||||
|
{
|
||||||
|
var index = NickColorHelper.GetPaletteIndex(nick, 12);
|
||||||
|
Assert.InRange(index, 0, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_DistributesAcrossPalette()
|
||||||
|
{
|
||||||
|
// Not a strict uniformity test — just that the hash isn't degenerate
|
||||||
|
var nicks = new[] { "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi" };
|
||||||
|
var distinct = nicks.Select(n => NickColorHelper.GetPaletteIndex(n, 12)).Distinct().Count();
|
||||||
|
Assert.True(distinct >= 3, $"Expected at least 3 distinct palette slots, got {distinct}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPaletteIndex_NonPositivePaletteSize_ReturnsZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, NickColorHelper.GetPaletteIndex("alice", 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user