diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs
index 0eee5a6..50d7a37 100644
--- a/src/EchoHub.Client/UI/Chat/ChatColors.cs
+++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs
@@ -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 FileAttr = new(new Color(100, 180, 255), Color.None);
+ /// The dim vertical rail (│) separating the nick column from message text.
+ public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None);
+
+ /// Horizontal date-separator rules (── Wed, Jul 16 ──…).
+ public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None);
+
+ /// The irssi-style "new messages" unread marker rule.
+ public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None);
+
///
/// Split text around @mentions and #channels, giving each the appropriate accent color.
/// Non-special text uses the provided default color.
diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs
index 92120a2..f7eef86 100644
--- a/src/EchoHub.Client/UI/Chat/ChatLine.cs
+++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs
@@ -23,6 +23,25 @@ public partial class ChatLine
/// Number of spaces to prepend on continuation lines when this line is word-wrapped.
public int ContinuationIndent { get; set; }
+ ///
+ /// Colored segments to prepend on continuation lines instead of plain spaces
+ /// (e.g. the nick-column rail " │ "). When set, takes
+ /// precedence over .
+ ///
+ public List? ContinuationPrefixSegments { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public string? RuleLabel { get; set; }
+
+ /// Color for a rule line; null falls back to .
+ public Attribute? RuleAttr { get; set; }
+
+ /// True for the "new messages" unread-marker rule so it can be removed on channel switch.
+ public bool IsUnreadMarker { get; set; }
+
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
@@ -43,9 +62,14 @@ public partial class ChatLine
///
public List 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];
+ var prefixSegments = ContinuationPrefixSegments;
+ if (prefixSegments is not null)
+ continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns());
+
var tokens = new List<(string grapheme, Attribute? color)>();
foreach (var segment in Segments)
foreach (var g in GraphemeHelper.GetGraphemes(segment.Text))
@@ -91,8 +115,13 @@ public partial class ChatLine
}
var segments = new List();
- if (!firstLine && continuationIndent > 0)
- segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
+ if (!firstLine)
+ {
+ if (prefixSegments is not null)
+ segments.AddRange(prefixSegments);
+ else if (continuationIndent > 0)
+ segments.Add(new ChatSegment(new string(' ', continuationIndent), null));
+ }
// Rebuild segments by grouping consecutive same-color tokens.
var sb = new StringBuilder();
@@ -120,6 +149,7 @@ public partial class ChatLine
return [this];
// Propagate metadata to all wrapped lines so they remain clickable
+ // and keep the mention highlight across continuation lines
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
@@ -127,6 +157,7 @@ public partial class ChatLine
wrapped.AttachmentKind = AttachmentKind;
wrapped.MessageId = MessageId;
wrapped.SenderUsername = SenderUsername;
+ wrapped.IsMention = IsMention;
}
return results;
diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
index de4805c..b3efb59 100644
--- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
+++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
@@ -1,3 +1,4 @@
+using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.DTOs;
@@ -15,8 +16,18 @@ namespace EchoHub.Client.UI.Chat;
///
public sealed class ChatMessageManager
{
+ /// Columns reserved for the right-aligned nick column (WeeChat-style).
+ public const int NickColWidth = 12;
+
+ /// Columns before message text starts: "HH:mm " + nick column + " │ ".
+ public const int ContentIndentCols = 6 + NickColWidth + 3;
+
private readonly Dictionary> _channelMessages = [];
private readonly Dictionary _channelUnread = [];
+ private readonly Dictionary _channelLastDate = [];
+ private readonly HashSet _markedChannels = [];
+ private readonly Dictionary _markerAnchor = [];
+ private readonly HashSet _mentionChannels = [];
private string _currentUser = string.Empty;
private string _currentChannel = string.Empty;
@@ -33,7 +44,16 @@ public sealed class ChatMessageManager
public string 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;
@@ -57,10 +77,14 @@ public sealed class ChatMessageManager
public void ClearUnread(string channelName)
{
_channelUnread[channelName] = 0;
+ _mentionChannels.Remove(channelName);
}
internal Dictionary GetUnreadCounts() => _channelUnread;
+ /// Channels with an unread @mention of the current user (cleared by ).
+ public IReadOnlySet MentionChannels => _mentionChannels;
+
// ── Mutations ────────────────────────────────────────────────────
///
@@ -75,19 +99,36 @@ public sealed class ChatMessageManager
_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)
messages.Add(line);
- if (message.ChannelName == _currentChannel)
- {
- MessagesChanged?.Invoke(message.ChannelName);
- }
- else
+ if (!isCurrent)
{
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
- MessagesChanged?.Invoke(message.ChannelName);
+ if (lines.Any(l => l.IsMention))
+ _mentionChannels.Add(message.ChannelName);
}
+
+ MessagesChanged?.Invoke(message.ChannelName);
}
///
@@ -101,24 +142,20 @@ public sealed class ChatMessageManager
_channelMessages[channelName] = messages;
}
- var time = FormatDateTime(DateTimeOffset.Now);
+ var time = FormatTime(DateTimeOffset.Now);
var textLines = text.Split('\n');
- messages.Add(new ChatLine(
- [
- new($"[{time}] ", ChatColors.TimestampAttr),
- new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
- ]));
+ var header = SystemHeaderSegments(time);
+ header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr));
+ messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() });
- var indent = new string(' ', $"[{time}] ** ".Length);
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
- messages.Add(new ChatLine(
- [
- new($"{indent}{line}", ChatColors.SystemAttr)
- ]));
+ var segments = RailPrefix();
+ segments.Add(new(line, ChatColors.SystemAttr));
+ messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
}
if (channelName == _currentChannel)
@@ -130,19 +167,16 @@ public sealed class ChatMessageManager
///
public void AddStatusMessage(string channelName, string username, string status)
{
- var time = FormatDateTime(DateTimeOffset.Now);
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new($"** {username} is now {status}", ChatColors.SystemAttr)
- };
+ var time = FormatTime(DateTimeOffset.Now);
+ var segments = SystemHeaderSegments(time);
+ segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr));
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
- messages.Add(new ChatLine(segments));
+ messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() });
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
@@ -169,6 +203,9 @@ public sealed class ChatMessageManager
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
+ _channelLastDate.Remove(channelName);
+ _markedChannels.Remove(channelName);
+ _markerAnchor.Remove(channelName);
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
@@ -179,9 +216,35 @@ public sealed class ChatMessageManager
///
public void LoadHistory(string channelName, List 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;
+ if (lastDate is { } date)
+ _channelLastDate[channelName] = date;
+ else
+ _channelLastDate.Remove(channelName);
+
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
@@ -200,14 +263,23 @@ public sealed class ChatMessageManager
.Select(l => l.MessageId!.Value)
.ToHashSet();
- var newLines = olderMessages
- .Where(m => !existingIds.Contains(m.Id))
- .SelectMany(FormatMessage)
- .ToList();
+ var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList();
+ var newLines = FormatWithDateRules(fresh, out var lastBatchDate);
if (newLines.Count == 0)
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);
if (channelName == _currentChannel)
@@ -226,6 +298,10 @@ public sealed class ChatMessageManager
{
_channelMessages.Clear();
_channelUnread.Clear();
+ _channelLastDate.Clear();
+ _markedChannels.Clear();
+ _markerAnchor.Clear();
+ _mentionChannels.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
}
@@ -234,12 +310,9 @@ public sealed class ChatMessageManager
private List FormatMessage(MessageDto message)
{
- var time = FormatDateTime(message.SentAt);
- var senderName = message.SenderUsername + ":";
- var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
-
- var indent = new string(' ', $"[{time}] {senderName} ".Length);
- var pad = new string(' ', 7);
+ var time = FormatTime(message.SentAt);
+ var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
+ ?? NickColorHelper.GetAttribute(message.SenderUsername);
var lines = new List();
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
@@ -250,25 +323,35 @@ public sealed class ChatMessageManager
{
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
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++)
- 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
{
var summary = attachments.Count switch
{
0 => " ",
- 1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]",
- _ => $" [{attachments.Count} attachments]",
+ 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
+ _ => $"[{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)
- 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)
{
switch (attachment.Kind)
@@ -279,24 +362,27 @@ public sealed class ChatMessageManager
foreach (var artLine in attachment.AsciiPreview.Split('\n'))
{
var trimmed = artLine.TrimEnd('\r');
- lines.Add(ChatLine.HasColorTags(trimmed)
- ? ChatLine.FromColoredText(pad + trimmed)
- : new ChatLine($"{pad}{trimmed}"));
+ var segments = RailPrefix();
+ if (ChatLine.HasColorTags(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)}]",
ChatColors.FileAttr, attachment));
break;
case Core.Models.AttachmentKind.Audio:
- lines.Add(AttachmentActionLine(pad,
+ lines.Add(AttachmentActionLine(
$"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
ChatColors.AudioAttr, attachment));
break;
default:
- lines.Add(AttachmentActionLine(pad,
+ lines.Add(AttachmentActionLine(
$"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]",
ChatColors.FileAttr, attachment));
break;
@@ -308,7 +394,7 @@ public sealed class ChatMessageManager
{
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
foreach (var embed in message.Embeds)
- lines.AddRange(FormatEmbed(embed, indent, chatWidth));
+ lines.AddRange(FormatEmbed(embed, chatWidth));
}
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
/// route activation (play audio, download file, save original image).
///
- 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
+ var segments = RailPrefix();
+ segments.Add(new(text, color));
+ return new ChatLine(segments)
{
- new(pad, null),
- new(text, color),
- });
- line.AttachmentUrl = attachment.Url;
- 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
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
- new(suffix, null)
+ AttachmentUrl = attachment.Url,
+ AttachmentFileName = attachment.FileName,
+ AttachmentKind = attachment.Kind,
+ ContinuationPrefixSegments = RailPrefix(),
};
- return new ChatLine(segments);
}
- private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
+ ///
+ /// Leading segments of a message header line: dim "HH:mm ", the right-aligned
+ /// nick column, and the " │ " rail. Message text follows at .
+ ///
+ private static List HeaderSegments(string time, string nick, Attribute? nickColor) =>
+ [
+ new($"{time} ", ChatColors.TimestampAttr),
+ new(PadNick(nick), nickColor),
+ new(" │ ", ChatColors.RailAttr),
+ ];
+
+ /// Header variant for system/status lines: "--" in the nick column.
+ private static List SystemHeaderSegments(string time) =>
+ [
+ new($"{time} ", ChatColors.TimestampAttr),
+ new(PadNick("--"), ChatColors.TimestampAttr),
+ new(" │ ", ChatColors.RailAttr),
+ ];
+
+ ///
+ /// Indent segments aligning continuation/attachment/embed lines under the message
+ /// text, extending the │ rail. Returns a fresh mutable list each call.
+ ///
+ private static List RailPrefix() =>
+ [
+ new(new string(' ', 6 + NickColWidth + 1), null),
+ new("│ ", ChatColors.RailAttr),
+ ];
+
+ ///
+ /// Right-aligns a nick into the fixed nick column, truncating over-long nicks
+ /// with an ellipsis. Grapheme/column aware.
+ ///
+ internal static string PadNick(string nick)
{
- var segments = new List
+ var cols = nick.GetColumns();
+ if (cols > NickColWidth)
{
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
- new(suffix, suffixColor)
- };
- return new ChatLine(segments);
+ var sb = new StringBuilder();
+ int used = 0;
+ 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;
}
- private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
+ internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy");
+
+ private static ChatLine DateRule(DateTime date)
{
- var segments = new List
+ var label = DateRuleLabel(date);
+ return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)])
{
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
+ RuleLabel = label,
+ RuleAttr = ChatColors.DateRuleAttr,
};
- segments.AddRange(ChatColors.SplitMentions(suffix));
- return new ChatLine(segments);
}
- private static List FormatEmbed(EmbedDto embed, string indent, int chatWidth)
+ private static ChatLine UnreadMarkerRule() =>
+ new([new("── new messages ──", ChatColors.UnreadMarkerAttr)])
+ {
+ RuleLabel = "new messages",
+ RuleAttr = ChatColors.UnreadMarkerAttr,
+ IsUnreadMarker = true,
+ };
+
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private List FormatWithDateRules(List messages, out DateTime? lastDate)
+ {
+ var lines = new List();
+ 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 FormatEmbed(EmbedDto embed, int chatWidth)
{
var lines = new List();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
- int indentCols = indent.GetColumns();
- int textWidth = chatWidth - indentCols - borderCols;
+ int textWidth = chatWidth - ContentIndentCols - 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, borderAttr),
- new ChatSegment(text, color)
- ]));
+ var segments = RailPrefix();
+ segments.Add(new ChatSegment(border, borderAttr));
+ segments.Add(new ChatSegment(text, color));
+ lines.Add(new ChatLine(segments));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
@@ -449,20 +606,11 @@ public sealed class ChatMessageManager
return result;
}
- private static string FormatDateTime(DateTimeOffset timestamp)
- {
- // Server timestamps arrive in UTC; convert to local before deciding the calendar day,
- // otherwise a "today" message near midnight is misclassified against the local date.
- var local = timestamp.ToLocalTime();
-
- // 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");
- }
+ // 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
+ // near midnight lands under the right date rule.
+ private static string FormatTime(DateTimeOffset timestamp) =>
+ timestamp.ToLocalTime().ToString("HH:mm");
internal static string FormatFileSize(long? bytes)
{
diff --git a/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs b/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs
new file mode 100644
index 0000000..1555cfd
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs
@@ -0,0 +1,103 @@
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// 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.
+///
+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"),
+ ];
+
+ ///
+ /// Builds banner lines centered for the given viewport width.
+ ///
+ public static List 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 { 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)]);
+ }
+}
diff --git a/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs b/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs
new file mode 100644
index 0000000..f6c9218
--- /dev/null
+++ b/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs
@@ -0,0 +1,60 @@
+using Terminal.Gui.Drawing;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Helpers;
+
+///
+/// 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.
+///
+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
+ ];
+
+ ///
+ /// Stable palette index for a nick: case-insensitive FNV-1a over the nick,
+ /// reduced modulo . Pure function (no Terminal.Gui
+ /// types) so it is unit-testable without a display driver.
+ ///
+ 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);
+ }
+
+ ///
+ /// The color attribute for a nick. Used as a fallback when the user has no
+ /// explicit nickname color set.
+ ///
+ public static Attribute GetAttribute(string nick) =>
+ Palette[GetPaletteIndex(nick, Palette.Length)];
+}
diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs
index 0b53d8c..5079c76 100644
--- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs
+++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs
@@ -17,6 +17,7 @@ public class ChannelListSource : IListDataSource
private readonly List _channelNames = [];
private readonly Dictionary _unreadCounts = [];
private readonly HashSet _protectedChannels = [];
+ private readonly HashSet _mentionChannels = [];
private string _activeChannel = string.Empty;
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 NormalAttr = new(Color.DarkGray, 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 channels, Dictionary unread, string activeChannel,
- IReadOnlySet? protectedChannels = null)
+ IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null)
{
_channelNames.Clear();
_channelNames.AddRange(channels);
@@ -40,6 +42,9 @@ public class ChannelListSource : IListDataSource
_protectedChannels.Clear();
if (protectedChannels is not null)
_protectedChannels.UnionWith(protectedChannels);
+ _mentionChannels.Clear();
+ if (mentionChannels is not null)
+ _mentionChannels.UnionWith(mentionChannels);
_activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent)
@@ -82,12 +87,18 @@ public class ChannelListSource : IListDataSource
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
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);
if (hasUnread)
{
- listView.SetAttribute(Resolve(BadgeAttr));
+ listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
}
}
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index 8f18f68..a332de6 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -319,6 +319,12 @@ public sealed partial class MainWindow : Runnable
_statusLabel.DrawingContent += OnStatusBarDrawContent;
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
ApplyColorSchemes();
@@ -1016,6 +1022,7 @@ public sealed partial class MainWindow : Runnable
public void UpdateStatusBar(string status)
{
_connectionStatus = status;
+ UpdateSpinner();
_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 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 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");
+
+ ///
+ /// Starts the spinner timer when entering a transitional connection state
+ /// (Connecting, Reconnecting, …); the timer stops itself once the state settles.
+ ///
+ 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)
{
@@ -1054,13 +1093,15 @@ public sealed partial class MainWindow : Runnable
Write(" EchoHub", Resolve(StatusBrandAttr));
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
- // Connection state with color
+ // Connection state with color; transitional states get an animated spinner
var statusAttr = _connectionStatus switch
{
"Connected" => StatusConnectedAttr,
"Disconnected" => StatusDisconnectedAttr,
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
};
+ if (IsTransitionalStatus)
+ Write($"{SpinnerFrames[_spinnerFrame]} ", statusAttr);
Write(_connectionStatus, Resolve(statusAttr));
// User
@@ -1079,6 +1120,28 @@ public sealed partial class MainWindow : Runnable
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
_statusLabel.SetAttribute(normalAttr);
while (col < width)
@@ -1181,24 +1244,29 @@ public sealed partial class MainWindow : Runnable
private void RefreshMessages()
{
+ var width = _messageList.Viewport.Width;
+
+ // Update cached width when viewport reports a valid value;
+ // fall back to last known width if viewport hasn't been laid out yet.
+ if (width > 0)
+ _lastChatWidth = width;
+ else
+ width = _lastChatWidth;
+
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
if (messages is not null)
{
- var width = _messageList.Viewport.Width;
-
- // Update cached width when viewport reports a valid value;
- // fall back to last known width if viewport hasn't been laid out yet.
- if (width > 0)
- _lastChatWidth = width;
- else
- width = _lastChatWidth;
-
var source = new ChatListSource();
if (width > 0)
{
foreach (var line in messages)
- source.AddRange(line.Wrap(width, line.ContinuationIndent));
+ {
+ if (line.RuleLabel is not null)
+ source.Add(ExpandRule(line, width));
+ else
+ source.AddRange(line.Wrap(width, line.ContinuationIndent));
+ }
}
else
{
@@ -1211,16 +1279,36 @@ public sealed partial class MainWindow : Runnable
}
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;
}
}
+ ///
+ /// Regenerates a separator rule (date change / unread marker) to span the
+ /// current viewport width: "── label ────────…".
+ ///
+ 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,
+ };
+ }
+
///
/// Refresh the channel list view, showing unread counts next to channel names.
///
private void RefreshChannelList()
{
- _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
+ _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
+ _channelProtected, _messageManager.MentionChannels);
_channelList.Source = _channelListSource;
// Restore selection to current channel
@@ -1296,8 +1384,11 @@ public sealed partial class MainWindow : Runnable
var text = roleTag.Length > 0
? $"{statusIcon} {roleTag} {name}"
: $"{statusIcon} {name}";
- var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
- return (text, nameColor, u.Username);
+ // Fall back to the deterministic per-nick palette so user-list colors
+ // 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();
_usersListSource.Update(displayItems);
diff --git a/src/EchoHub.Tests/NickColorHelperTests.cs b/src/EchoHub.Tests/NickColorHelperTests.cs
new file mode 100644
index 0000000..26d0c82
--- /dev/null
+++ b/src/EchoHub.Tests/NickColorHelperTests.cs
@@ -0,0 +1,55 @@
+using EchoHub.Client.UI.Helpers;
+using Xunit;
+
+namespace EchoHub.Tests;
+
+///
+/// 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.
+///
+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));
+ }
+}