using System.Text; using System.Text.RegularExpressions; using EchoHub.Client.UI.Helpers; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Terminal.Gui.Drawing; using Terminal.Gui.Text; using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI.Chat; /// /// Owns chat message storage, formatting, and mutation. /// Fires when a channel's message list is modified /// so the UI layer can refresh. /// 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 readonly Dictionary _lastRead = []; private readonly Dictionary _channelNewestId = []; private string _currentUser = string.Empty; private string _currentChannel = string.Empty; private int _chatWidth; /// /// Fired after any mutation to a channel's messages. Parameter is the channel name. /// public event Action? MessagesChanged; /// /// The currently active channel (used for unread tracking and @mention detection). /// public string CurrentChannel { get => _currentChannel; set { if (_currentChannel == value) return; // Leaving a channel consumes its "new messages" marker so the next // unread burst gets a fresh one (irssi behavior), and everything // visible up to now counts as read. RemoveUnreadMarker(_currentChannel); MarkRead(_currentChannel); _currentChannel = value; } } public string CurrentUser => _currentUser; public void SetCurrentUser(string username) => _currentUser = username; public void SetChatWidth(int width) => _chatWidth = width; // ── Queries ────────────────────────────────────────────────────── public List? GetMessages(string channelName) { return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null; } public int GetUnreadCount(string channelName) { return _channelUnread.TryGetValue(channelName, out var count) ? count : 0; } public void ClearUnread(string channelName) { _channelUnread[channelName] = 0; _mentionChannels.Remove(channelName); MarkRead(channelName); } /// /// Last message the user has read per channel — persisted by the orchestrator so /// unread/mention state can be seeded from history on the next connect. /// public IReadOnlyDictionary LastReadIds => _lastRead; private void MarkRead(string channelName) { if (!string.IsNullOrEmpty(channelName) && _channelNewestId.TryGetValue(channelName, out var newest)) _lastRead[channelName] = newest; } internal Dictionary GetUnreadCounts() => _channelUnread; /// Channels with an unread @mention of the current user (cleared by ). public IReadOnlySet MentionChannels => _mentionChannels; // ── Mutations ──────────────────────────────────────────────────── /// /// Format and store a received message. Increments unread count if not the active channel. /// public void AddMessage(MessageDto message) { var lines = FormatMessage(message); if (!_channelMessages.TryGetValue(message.ChannelName, out var messages)) { 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; _channelNewestId[message.ChannelName] = message.Id; if (isCurrent) _lastRead[message.ChannelName] = message.Id; // 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 (!isCurrent) { _channelUnread.TryGetValue(message.ChannelName, out var count); _channelUnread[message.ChannelName] = count + 1; if (lines.Any(l => l.IsMention)) _mentionChannels.Add(message.ChannelName); } MessagesChanged?.Invoke(message.ChannelName); } /// /// Add a system/informational message to a channel with colored styling. /// public void AddSystemMessage(string channelName, string text) { if (!_channelMessages.TryGetValue(channelName, out var messages)) { messages = []; _channelMessages[channelName] = messages; } var time = FormatTime(DateTimeOffset.Now); var textLines = text.Split('\n'); var header = SystemHeaderSegments(time); header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr)); messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() }); for (int i = 1; i < textLines.Length; i++) { var line = textLines[i].TrimEnd('\r'); if (string.IsNullOrWhiteSpace(line)) continue; var segments = RailPrefix(); segments.Add(new(line, ChatColors.SystemAttr)); messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() }); } if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } /// /// Add a status change message to a channel with colored styling. /// public void AddStatusMessage(string channelName, string username, string status) { 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) { ContinuationPrefixSegments = RailPrefix() }); if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } /// /// Remove all lines associated with a specific message ID. /// public void RemoveMessage(string channelName, Guid messageId) { if (_channelMessages.TryGetValue(channelName, out var messages)) { messages.RemoveAll(l => l.MessageId == messageId); if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } } /// /// Clear all messages from a specific channel. /// public void ClearChannelMessages(string channelName) { if (_channelMessages.TryGetValue(channelName, out var messages)) { messages.Clear(); _channelLastDate.Remove(channelName); _markedChannels.Remove(channelName); _markerAnchor.Remove(channelName); if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } } /// /// Load historical messages into a channel, replacing any existing messages. /// When is given (persisted from a previous session), /// messages after it seed the unread count, @mention highlight, and the /// "new messages" marker — so activity that happened while offline still lights up. /// public void LoadHistory(string channelName, List messages, Guid? lastReadId = null) { var formatted = FormatWithDateRules(messages, out var lastDate); if (messages.Count > 0) _channelNewestId[channelName] = messages[^1].Id; // 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); } } else if (lastReadId is { } lastRead && messages.Count > 0) { SeedUnreadFromHistory(channelName, messages, formatted, lastRead); } _channelMessages[channelName] = formatted; if (lastDate is { } date) _channelLastDate[channelName] = date; else _channelLastDate.Remove(channelName); MessagesChanged?.Invoke(channelName); } /// /// Reconstructs unread state from a persisted last-read message id: places the /// "new messages" marker before the first unread message and, for inactive /// channels, seeds the unread count and @mention highlight. A last-read id that /// is no longer inside the fetched window treats the whole window as unread. /// private void SeedUnreadFromHistory(string channelName, List messages, List formatted, Guid lastReadId) { // FindIndex miss (-1 → 0) means the last-read message is older than the fetched // window: everything in the window counts as unread. var firstUnread = messages.FindIndex(m => m.Id == lastReadId) + 1; if (firstUnread >= messages.Count) return; // everything read var anchor = messages[firstUnread]; var lineIdx = formatted.FindIndex(l => l.MessageId == anchor.Id); if (lineIdx < 0) return; formatted.Insert(lineIdx, UnreadMarkerRule()); _markedChannels.Add(channelName); _markerAnchor[channelName] = anchor.Id; // The active channel shows the marker but is being read right now — // badges and mention highlights are only for background channels. if (channelName == _currentChannel) return; _channelUnread[channelName] = messages.Count - firstUnread; if (!string.IsNullOrEmpty(_currentUser)) { var pattern = $@"@{Regex.Escape(_currentUser)}\b"; if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase))) _mentionChannels.Add(channelName); } } /// /// Prepend older messages at the front of a channel's buffer, skipping any that are already present. /// Fires when new lines are actually inserted. /// public void PrependHistory(string channelName, List olderMessages) { if (!_channelMessages.TryGetValue(channelName, out var existing)) return; var existingIds = existing .Where(l => l.MessageId.HasValue) .Select(l => l.MessageId!.Value) .ToHashSet(); 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) HistoryPrepended?.Invoke(channelName); } /// /// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name. /// public event Action? HistoryPrepended; /// /// Reset all message state (used on disconnect). /// public void ClearAll() { _channelMessages.Clear(); _channelUnread.Clear(); _channelLastDate.Clear(); _markedChannels.Clear(); _markerAnchor.Clear(); _mentionChannels.Clear(); _lastRead.Clear(); _channelNewestId.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; } // ── Formatting ─────────────────────────────────────────────────── private List FormatMessage(MessageDto message) { 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); var attachments = message.Attachments ?? []; // Header line: caption text, or a summary when the message is attachments-only if (hasContent) { var displayContent = EmojiHelper.ReplaceEmoji(message.Content); var contentLines = displayContent.Split('\n'); 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++) { 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]", }; var header = HeaderSegments(time, message.SenderUsername, senderColor); header.Add(new(summary, null)); lines.Add(new ChatLine(header)); } foreach (var l in lines) l.ContinuationPrefixSegments = RailPrefix(); // One block per attachment — every block hangs off the nick-column rail foreach (var attachment in attachments) { switch (attachment.Kind) { case Core.Models.AttachmentKind.Image: if (!string.IsNullOrWhiteSpace(attachment.AsciiPreview)) { foreach (var artLine in attachment.AsciiPreview.Split('\n')) { var trimmed = artLine.TrimEnd('\r'); 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( $"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr, attachment)); break; case Core.Models.AttachmentKind.Audio: lines.Add(AttachmentActionLine( $"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", ChatColors.AudioAttr, attachment)); break; default: lines.Add(AttachmentActionLine( $"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr, attachment)); break; } } // Link embeds (from caption URLs) if (message.Embeds is { Count: > 0 }) { var chatWidth = _chatWidth > 0 ? _chatWidth : 80; foreach (var embed in message.Embeds) lines.AddRange(FormatEmbed(embed, chatWidth)); } foreach (var line in lines) { line.MessageId = message.Id; line.SenderUsername = message.SenderUsername; } if (hasContent && !string.IsNullOrEmpty(_currentUser)) { var pattern = $@"@{Regex.Escape(_currentUser)}\b"; if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)) { foreach (var line in lines) line.IsMention = true; } } return lines; } /// /// 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 text, Attribute color, AttachmentDto attachment) { var segments = RailPrefix(); segments.Add(new(text, color)); return new ChatLine(segments) { AttachmentUrl = attachment.Url, AttachmentFileName = attachment.FileName, AttachmentKind = attachment.Kind, ContinuationPrefixSegments = RailPrefix(), }; } /// /// 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 cols = nick.GetColumns(); if (cols > NickColWidth) { 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; } 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, }; } 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 textWidth = chatWidth - ContentIndentCols - borderCols; if (textWidth < 20) textWidth = 20; var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr; void AddTextLine(string text, Attribute? 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)) AddTextLine(embed.SiteName, borderAttr); if (!string.IsNullOrWhiteSpace(embed.Title)) { foreach (var wrapped in WordWrap(embed.Title, textWidth)) AddTextLine(wrapped, ChatColors.EmbedTitleAttr); } if (!string.IsNullOrWhiteSpace(embed.Description)) { foreach (var wrapped in WordWrap(embed.Description, textWidth)) AddTextLine(wrapped, ChatColors.EmbedDescAttr); } return lines; } private static List WordWrap(string text, int maxCols) { if (maxCols <= 0) return [text]; var result = new List(); var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var currentLine = ""; foreach (var word in words) { var candidate = currentLine.Length == 0 ? word : currentLine + " " + word; if (candidate.GetColumns() <= maxCols) { currentLine = candidate; } else { if (currentLine.Length > 0) result.Add(currentLine); currentLine = word; } } if (currentLine.Length > 0) result.Add(currentLine); return result; } // 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) { if (bytes is null or 0) return "?"; return bytes.Value switch { < 1024 => $"{bytes.Value} B", < 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB", < 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB", _ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB" }; } }