mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
- Introduced a new migration to add the IsSystem column to the Channels table. - Updated the DbContext model snapshot to reflect the new IsSystem property. - Enhanced the ChannelService to manage system channels, including creation, visibility control, and protection against deletion. - Implemented ServerLogsService to handle live server logging, including reading from log files and managing access based on user roles. - Created ServerLogsSink to queue log events for streaming to the live log room. - Developed ServerLogsStreamService to stream log events to clients in real-time. - Added configuration options for server logs in appsettings. - Implemented comprehensive unit tests for channel service system channel behavior and server logs functionality.
1616 lines
57 KiB
C#
1616 lines
57 KiB
C#
using System.Diagnostics;
|
|
using System.Text.RegularExpressions;
|
|
using EchoHub.Client.Services;
|
|
using EchoHub.Client.Themes;
|
|
using EchoHub.Client.UI.Chat;
|
|
using EchoHub.Client.UI.Helpers;
|
|
using EchoHub.Client.UI.ListSources;
|
|
using EchoHub.Core.DTOs;
|
|
using EchoHub.Core.Models;
|
|
using Serilog;
|
|
using Terminal.Gui.App;
|
|
using Terminal.Gui.Configuration;
|
|
using Terminal.Gui.Drawing;
|
|
using Terminal.Gui.Drivers;
|
|
using Terminal.Gui.Input;
|
|
using Terminal.Gui.Text;
|
|
using Terminal.Gui.ViewBase;
|
|
using Terminal.Gui.Views;
|
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
|
|
|
namespace EchoHub.Client.UI;
|
|
|
|
/// <summary>
|
|
/// Main Terminal.Gui window for the EchoHub chat client.
|
|
/// </summary>
|
|
public sealed partial class MainWindow : Runnable
|
|
{
|
|
private readonly IApplication _app;
|
|
private readonly ListView _channelList;
|
|
private readonly ListView _messageList;
|
|
private readonly TextView _inputField;
|
|
private readonly FrameView _chatFrame;
|
|
private readonly FrameView _inputFrame;
|
|
private readonly Label _statusLabel;
|
|
private readonly Label _topicLabel;
|
|
private MenuBar _menuBar;
|
|
|
|
// Online users panel
|
|
private readonly FrameView _usersFrame;
|
|
private readonly ListView _usersList;
|
|
private readonly UserListSource _usersListSource;
|
|
private bool _usersPanelVisible = true;
|
|
private const int UsersPanelWidth = 22;
|
|
private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message";
|
|
private const KeyCode F2Key = KeyCode.F2;
|
|
private bool _hasStagedAttachments;
|
|
|
|
internal static readonly string AppVersion =
|
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
|
|
|
// Key bindings as KeyCode constants: comparing raw KeyCodes avoids Key.Equals (which also
|
|
// checks Handled), and constants make them usable as switch case labels.
|
|
private const KeyCode EnterKey = KeyCode.Enter;
|
|
private const KeyCode NewlineKey = KeyCode.N | KeyCode.CtrlMask;
|
|
private const KeyCode AltQKey = KeyCode.Q | KeyCode.AltMask;
|
|
private const KeyCode TabKey = KeyCode.Tab;
|
|
private const KeyCode CtrlKKey = KeyCode.K | KeyCode.CtrlMask;
|
|
private const KeyCode CtrlVKey = KeyCode.V | KeyCode.CtrlMask;
|
|
private const KeyCode CtrlXKey = KeyCode.X | KeyCode.CtrlMask;
|
|
private const KeyCode CtrlCKey = KeyCode.C | KeyCode.CtrlMask;
|
|
private const KeyCode CtrlYKey = KeyCode.Y | KeyCode.CtrlMask;
|
|
private const KeyCode F6Key = KeyCode.F6;
|
|
|
|
// Available slash commands for Tab autocomplete
|
|
private static readonly string[] SlashCommands =
|
|
[
|
|
"/status", "/nick", "/color", "/theme", "/send", "/me", "/banner",
|
|
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
|
"/mute", "/unmute", "/role", "/invite", "/export", "/deleteaccount",
|
|
"/nuke", "/test-sound", "/quit", "/help"
|
|
];
|
|
|
|
private readonly List<string> _channelNames = [];
|
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
|
private readonly HashSet<string> _channelProtected = [];
|
|
private readonly HashSet<string> _systemChannels = [];
|
|
private readonly ChannelListSource _channelListSource;
|
|
private readonly ChatMessageManager _messageManager;
|
|
private string _connectionStatus = "Disconnected";
|
|
private int _lastChatWidth;
|
|
|
|
/// <summary>
|
|
/// Fired when the user selects a channel. Parameter is the channel name.
|
|
/// </summary>
|
|
public event Action<string>? OnChannelSelected;
|
|
|
|
/// <summary>
|
|
/// Fired when the user presses Enter in the input field. Parameters: channel name, message content.
|
|
/// </summary>
|
|
public event Action<string, string>? OnMessageSubmitted;
|
|
|
|
/// <summary>
|
|
/// Fired when local files arrive via paste or drag-and-drop to be staged as attachments.
|
|
/// Parameters: channel name, absolute paths of existing files.
|
|
/// </summary>
|
|
public event Action<string, IReadOnlyList<string>>? OnFilesStaged;
|
|
|
|
/// <summary>
|
|
/// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a
|
|
/// screenshot tool). Parameters: channel name, PNG-encoded image bytes.
|
|
/// </summary>
|
|
public event Action<string, byte[]>? OnImagePasted;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to connect via the menu.
|
|
/// </summary>
|
|
public event Action? OnConnectRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to disconnect via the menu.
|
|
/// </summary>
|
|
public event Action? OnDisconnectRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to logout (disconnect + revoke session).
|
|
/// </summary>
|
|
public event Action? OnLogoutRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to open their profile panel.
|
|
/// </summary>
|
|
public event Action? OnProfileRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to set their status.
|
|
/// </summary>
|
|
public event Action? OnStatusRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user selects a theme from the menu. Parameter is the theme name.
|
|
/// </summary>
|
|
public event Action<string>? OnThemeSelected;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to check for updates.
|
|
/// </summary>
|
|
public event Action? OnCheckForUpdatesRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to view saved servers.
|
|
/// </summary>
|
|
public event Action? OnSavedServersRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
|
|
/// </summary>
|
|
public event Action? OnLoadMoreRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to create a new channel.
|
|
/// </summary>
|
|
public event Action? OnCreateChannelRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to delete the current channel.
|
|
/// </summary>
|
|
public event Action? OnDeleteChannelRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to rollback to the previous version.
|
|
/// </summary>
|
|
public event Action? OnRollbackRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
|
/// </summary>
|
|
public event Action<string, string>? OnAudioPlayRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
|
|
/// </summary>
|
|
public event Action<string, string>? OnFileDownloadRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName.
|
|
/// </summary>
|
|
public event Action<string, string>? OnImageSaveRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates an image's "[open]" action to view it without saving.
|
|
/// Parameters: attachmentUrl, fileName.
|
|
/// </summary>
|
|
public event Action<string, string>? OnImageOpenRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
|
/// </summary>
|
|
public event Action<Guid>? OnDeleteMessageRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user picks "Reply" on a message. Parameters: message id, sender username,
|
|
/// a short plain-text snippet for the reply strip.
|
|
/// </summary>
|
|
public event Action<Guid, string, string>? OnReplyRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user cancels a pending reply (Esc in the input field).
|
|
/// </summary>
|
|
public event Action? OnReplyCancelRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
|
/// </summary>
|
|
public event Action<string>? OnUserProfileRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user activates a #channel reference in a message. Parameter is the channel name.
|
|
/// </summary>
|
|
public event Action<string>? OnChannelJoinRequested;
|
|
|
|
/// <summary>
|
|
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
|
|
/// </summary>
|
|
public event Action? OnSearchRequested;
|
|
|
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
|
{
|
|
_app = app;
|
|
_messageManager = messageManager;
|
|
_messageManager.MessagesChanged += OnMessagesChanged;
|
|
_messageManager.HistoryPrepended += OnHistoryPrepended;
|
|
Arrangement = ViewArrangement.Fixed;
|
|
|
|
// Menu bar at the top
|
|
_menuBar = BuildMenuBar();
|
|
Add(_menuBar);
|
|
|
|
// Left panel - channels
|
|
var channelsFrame = new FrameView
|
|
{
|
|
Title = "Channels",
|
|
X = 0,
|
|
Y = 1, // below menu bar
|
|
Width = 22,
|
|
Height = Dim.Fill(1) // leave room for status bar
|
|
};
|
|
|
|
_channelList = new ListView
|
|
{
|
|
X = 0,
|
|
Y = 0,
|
|
Width = Dim.Fill(),
|
|
Height = Dim.Fill()
|
|
};
|
|
_channelListSource = new ChannelListSource();
|
|
_channelList.Source = _channelListSource;
|
|
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
|
channelsFrame.Add(_channelList);
|
|
Add(channelsFrame);
|
|
|
|
// Topic bar — sits above the chat frame in the right column
|
|
_topicLabel = new Label
|
|
{
|
|
Text = "",
|
|
X = 22,
|
|
Y = 1,
|
|
Width = Dim.Fill(UsersPanelWidth),
|
|
Height = 1,
|
|
Visible = false
|
|
};
|
|
Add(_topicLabel);
|
|
|
|
// Center panel - messages
|
|
_chatFrame = new FrameView
|
|
{
|
|
Title = "Chat",
|
|
X = 22,
|
|
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
|
Width = Dim.Fill(UsersPanelWidth),
|
|
Height = Dim.Fill(6) // leave room for input area and status bar
|
|
};
|
|
|
|
_messageList = new ListView
|
|
{
|
|
X = 0,
|
|
Y = 0,
|
|
Width = Dim.Fill(),
|
|
Height = Dim.Fill()
|
|
};
|
|
_messageList.Source = new ChatListSource();
|
|
_messageList.Accepting += OnMessageListAccepting;
|
|
_messageList.KeyDown += OnMessageListKeyDown;
|
|
_messageList.MouseEvent += OnMessageListMouseEvent;
|
|
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
|
_messageList.VerticalScrollBar.Visible = true;
|
|
|
|
_chatFrame.Add(_messageList);
|
|
Add(_chatFrame);
|
|
|
|
// Bottom input area
|
|
_inputFrame = new FrameView
|
|
{
|
|
Title = DefaultInputTitle,
|
|
X = 22,
|
|
Y = Pos.Bottom(_chatFrame),
|
|
Width = Dim.Fill(UsersPanelWidth),
|
|
Height = 5
|
|
};
|
|
|
|
_inputField = new TextView
|
|
{
|
|
X = 0,
|
|
Y = 0,
|
|
Width = Dim.Fill(),
|
|
Height = Dim.Fill(),
|
|
WordWrap = true
|
|
};
|
|
// Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw
|
|
// Win32Exception when another process holds the clipboard, crashing the app.
|
|
// Rebind it to delete-word-backward (readline behavior), which never touches the clipboard.
|
|
_inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft);
|
|
_inputField.KeyDown += OnInputKeyDown;
|
|
_inputField.ContentsChanged += OnInputContentsChanged;
|
|
_inputFrame.Add(_inputField);
|
|
Add(_inputFrame);
|
|
|
|
// Right panel - online users
|
|
_usersFrame = new FrameView
|
|
{
|
|
Title = "Users",
|
|
X = Pos.AnchorEnd(UsersPanelWidth),
|
|
Y = 1,
|
|
Width = UsersPanelWidth,
|
|
Height = Dim.Fill(1)
|
|
};
|
|
|
|
_usersList = new ListView
|
|
{
|
|
X = 0,
|
|
Y = 0,
|
|
Width = Dim.Fill(),
|
|
Height = Dim.Fill()
|
|
};
|
|
_usersListSource = new UserListSource();
|
|
_usersList.Source = _usersListSource;
|
|
_usersList.Accepting += OnUsersListAccepting;
|
|
_usersFrame.Add(_usersList);
|
|
Add(_usersFrame);
|
|
|
|
// Status bar at the very bottom — custom drawing for colored connection state
|
|
_statusLabel = new Label
|
|
{
|
|
Text = "",
|
|
X = 0,
|
|
Y = Pos.AnchorEnd(1),
|
|
Width = Dim.Fill(),
|
|
Height = 1
|
|
};
|
|
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
|
_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();
|
|
|
|
// Re-wrap messages when the chat area is resized
|
|
// Subscribe to both ListView and FrameView viewport changes for reliable resize detection
|
|
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
|
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
|
|
|
// Window-level key handling for Alt+Q (quit), F2 (toggle users panel)
|
|
KeyDown += OnWindowKeyDown;
|
|
}
|
|
|
|
private string? _stagedTitleFragment;
|
|
private string? _replyTitleFragment;
|
|
|
|
/// <summary>
|
|
/// Updates the attachment staging indicator shown on the input frame's title, including the
|
|
/// current ASCII-art size for images. Passing an empty list restores the default hint.
|
|
/// </summary>
|
|
public void SetStagedAttachments(IReadOnlyList<string> fileNames, string asciiSizeLabel)
|
|
{
|
|
_hasStagedAttachments = fileNames.Count > 0;
|
|
if (fileNames.Count == 0)
|
|
{
|
|
_stagedTitleFragment = null;
|
|
}
|
|
else
|
|
{
|
|
var names = string.Join(", ", fileNames);
|
|
if (names.Length > 45)
|
|
names = names[..42] + "...";
|
|
_stagedTitleFragment = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
|
}
|
|
UpdateInputTitle();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows/clears the "replying to" strip on the input frame's title. Pass null to clear.
|
|
/// </summary>
|
|
public void SetReplyingTo(string? label)
|
|
{
|
|
_replyTitleFragment = label is null ? null : $"↩ Replying to {label} │ Esc=cancel";
|
|
UpdateInputTitle();
|
|
}
|
|
|
|
public bool HasPendingReplyIndicator => _replyTitleFragment is not null;
|
|
|
|
private void UpdateInputTitle()
|
|
{
|
|
// Read-only channels (the live log room) override any reply/staged hint.
|
|
if (IsCurrentChannelReadOnly)
|
|
{
|
|
_inputFrame.Title = "Read-only channel — you cannot type here";
|
|
_inputFrame.SetNeedsDraw();
|
|
return;
|
|
}
|
|
|
|
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
|
|
{
|
|
(null, null) => DefaultInputTitle,
|
|
({ } reply, null) => reply,
|
|
(null, { } staged) => staged,
|
|
({ } reply, { } staged) => $"{reply} │ {staged}",
|
|
};
|
|
_inputFrame.SetNeedsDraw();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the currently registered color schemes to all views.
|
|
/// Call after theme changes to refresh colors.
|
|
/// </summary>
|
|
public void ApplyColorSchemes()
|
|
{
|
|
var baseScheme = SchemeManager.GetScheme("Base");
|
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
|
var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme;
|
|
|
|
if (baseScheme is not null)
|
|
{
|
|
this.SetScheme(baseScheme);
|
|
|
|
// Propagate to all child views that should use the base scheme
|
|
foreach (var sub in SubViews)
|
|
{
|
|
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
|
sub.SetScheme(baseScheme);
|
|
|
|
// Frame borders (and their titles) take the theme's border colors, so
|
|
// themes can tone them down independently of text (e.g. transparent
|
|
// themes use dim gray instead of eye-catching white)
|
|
if (sub is FrameView frame && borderScheme is not null)
|
|
frame.Border?.SetScheme(borderScheme);
|
|
}
|
|
}
|
|
|
|
if (menuScheme is not null)
|
|
{
|
|
_menuBar.SetScheme(menuScheme);
|
|
_statusLabel.SetScheme(menuScheme);
|
|
_topicLabel.SetScheme(menuScheme);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the menu bar with File, Server, User menus and a theme submenu.
|
|
/// </summary>
|
|
private MenuBar BuildMenuBar()
|
|
{
|
|
// Build theme menu items and prepend them with a separator header
|
|
var themeItems = new List<MenuItem>();
|
|
foreach (var t in Themes.ThemeManager.GetAvailableThemes())
|
|
{
|
|
var name = t.Name;
|
|
themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty));
|
|
}
|
|
|
|
// Combine user items with theme items, separated by a line
|
|
var allUserItems = new List<View>
|
|
{
|
|
new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty),
|
|
new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty),
|
|
new Line()
|
|
};
|
|
allUserItems.AddRange(themeItems);
|
|
|
|
var fileItems = new List<View>();
|
|
if (UpdateBackupService.BackupExists())
|
|
{
|
|
var info = UpdateBackupService.GetBackupInfo();
|
|
var label = info is not null ? $"_Rollback to v{info.Version}..." : "_Rollback Update...";
|
|
fileItems.Add(new MenuItem(label, "Restore previous version", () => OnRollbackRequested?.Invoke(), Key.Empty));
|
|
fileItems.Add(new Line());
|
|
}
|
|
fileItems.Add(new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty));
|
|
fileItems.Add(new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty));
|
|
|
|
var menuBar = new MenuBar(
|
|
[
|
|
new MenuBarItem("_File", fileItems),
|
|
new MenuBarItem("_Server", new View[]
|
|
{
|
|
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
|
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
|
|
new Line(),
|
|
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
|
new Line(),
|
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
|
|
new Line(),
|
|
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
|
|
}),
|
|
new MenuBarItem("_User", allUserItems)
|
|
]);
|
|
menuBar.X = 0;
|
|
menuBar.Y = 0;
|
|
menuBar.Width = Dim.Fill();
|
|
|
|
// Workaround: Make CommandView (title text area) mouse-transparent on each MenuBarItem.
|
|
// Without this, clicks on the text hit the CommandView sub-view whose Source propagates
|
|
// as a plain View — MenuBar.OnAccepting checks "sourceView is MenuBarItem" which fails.
|
|
// Making CommandView transparent lets clicks pass through to the MenuBarItem itself.
|
|
foreach (var mbi in menuBar.SubViews.OfType<MenuBarItem>())
|
|
{
|
|
mbi.CommandView.ViewportSettings |= ViewportSettingsFlags.TransparentMouse;
|
|
}
|
|
|
|
return menuBar;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuilds and replaces the menu bar (e.g., after theme list changes).
|
|
/// </summary>
|
|
public void RefreshMenuBar()
|
|
{
|
|
Remove(_menuBar);
|
|
_menuBar = BuildMenuBar();
|
|
Add(_menuBar);
|
|
ApplyColorSchemes();
|
|
SetNeedsDraw();
|
|
}
|
|
|
|
private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs<int?> e)
|
|
{
|
|
var index = e.NewValue;
|
|
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
|
{
|
|
var channelName = _channelNames[index.Value];
|
|
if (channelName != _messageManager.CurrentChannel)
|
|
{
|
|
SwitchToChannel(channelName);
|
|
OnChannelSelected?.Invoke(channelName);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnMessageListAccepting(object? sender, CommandEventArgs e)
|
|
{
|
|
if (_messageList.Source is not ChatListSource source)
|
|
return;
|
|
|
|
var index = _messageList.SelectedItem;
|
|
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
|
return;
|
|
|
|
var line = source.GetLine(index.Value);
|
|
if (line is null) return;
|
|
|
|
// A reply's quote line jumps to the original message (if it's in the buffer)
|
|
if (line.JumpToMessageId is { } jumpTarget)
|
|
{
|
|
ScrollToMessage(jumpTarget);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// Audio/file attachments take priority
|
|
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
|
{
|
|
if (line.AttachmentKind == AttachmentKind.Audio)
|
|
{
|
|
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
if (line.AttachmentKind == AttachmentKind.File)
|
|
{
|
|
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
if (line.AttachmentKind == AttachmentKind.Image)
|
|
{
|
|
// Keyboard/default activation opens the image for viewing;
|
|
// saving is the mouse span or the context menu.
|
|
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
var lineText = line.ToString();
|
|
|
|
// Check for @mention — open mentioned user's profile
|
|
// Negative lookbehind prevents matching emails (user@domain)
|
|
var mentionMatch = ClickMentionRegex().Match(lineText);
|
|
if (mentionMatch.Success)
|
|
{
|
|
OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// Check for #channel — join/switch to that channel
|
|
// Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123)
|
|
var channelMatch = ClickChannelRegex().Match(lineText);
|
|
if (channelMatch.Success)
|
|
{
|
|
OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
// Default: open sender's profile
|
|
if (line.SenderUsername is not null)
|
|
{
|
|
OnUserProfileRequested?.Invoke(line.SenderUsername);
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
private void OnMessageListKeyDown(object? sender, Key e)
|
|
{
|
|
// F6 returns focus to the input box.
|
|
if (e.KeyCode == F6Key)
|
|
{
|
|
_inputField.SetFocus();
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
|
|
if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode)
|
|
return;
|
|
|
|
if (_messageList.Source is not ChatListSource source)
|
|
return;
|
|
|
|
var index = _messageList.SelectedItem;
|
|
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
|
|
return;
|
|
|
|
var line = source.GetLine(index.Value);
|
|
if (line?.MessageId is not { } messageId)
|
|
return;
|
|
|
|
// Server enforces the real permission (own message, or Mod+ over a lower role);
|
|
// the client just confirms intent and lets the server reject if disallowed.
|
|
ConfirmDeleteMessage(messageId);
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
|
{
|
|
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
|
|
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
|
return;
|
|
|
|
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
|
return;
|
|
|
|
var index = _messageList.TopItem + pos.Y;
|
|
if (index < 0 || index >= source.Count)
|
|
return;
|
|
|
|
// Left-click only activates the "[open]" / "[save original]" brackets on an
|
|
// attachment action line; anywhere else it falls through to normal selection.
|
|
if (leftClick)
|
|
{
|
|
var clicked = source.GetLine(index);
|
|
if (clicked?.ActionSpans is { } spans
|
|
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
|
|
{
|
|
foreach (var span in spans)
|
|
{
|
|
if (pos.X < span.StartCol || pos.X > span.EndCol)
|
|
continue;
|
|
|
|
if (span.Action == AttachmentAction.OpenImage)
|
|
OnImageOpenRequested?.Invoke(url, name);
|
|
else
|
|
OnImageSaveRequested?.Invoke(url, name);
|
|
e.Handled = true;
|
|
return;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
|
_messageList.SelectedItem = index;
|
|
_messageList.SetFocus();
|
|
|
|
var line = source.GetLine(index);
|
|
if (line is null)
|
|
return;
|
|
|
|
ShowMessageContextMenu(line, e.ScreenPosition);
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds and shows a right-click context menu for a message line: attachment actions,
|
|
/// mention/profile for the sender, copy, and delete (permission enforced server-side).
|
|
/// </summary>
|
|
private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition)
|
|
{
|
|
var items = new List<View>();
|
|
var sender = line.SenderUsername;
|
|
|
|
if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case AttachmentKind.Image:
|
|
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
|
|
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
|
break;
|
|
case AttachmentKind.Audio:
|
|
items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty));
|
|
break;
|
|
default:
|
|
items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (sender is not null && line.MessageId is { } replyTargetId)
|
|
{
|
|
items.Add(new MenuItem("Reply", "", () =>
|
|
{
|
|
// Strip the "HH:mm nick │ " header so the strip shows just the text
|
|
var snippet = line.ToString();
|
|
var railIdx = snippet.IndexOf(" │ ", StringComparison.Ordinal);
|
|
if (railIdx >= 0)
|
|
snippet = snippet[(railIdx + 3)..];
|
|
OnReplyRequested?.Invoke(replyTargetId, sender, snippet.Trim());
|
|
_inputField.SetFocus();
|
|
}, Key.Empty));
|
|
}
|
|
|
|
if (sender is not null)
|
|
{
|
|
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
|
items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty));
|
|
}
|
|
|
|
items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty));
|
|
|
|
if (line.MessageId is { } messageId)
|
|
{
|
|
items.Add(new MenuItem("Copy message ID", "", () => CopyToClipboard(messageId.ToString()), Key.Empty));
|
|
items.Add(new Line());
|
|
items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty));
|
|
}
|
|
|
|
if (items.Count == 0)
|
|
return;
|
|
|
|
var menu = new PopoverMenu(items);
|
|
_app.Popovers?.Register(menu);
|
|
menu.MakeVisible(screenPosition);
|
|
}
|
|
|
|
private void MentionUser(string username)
|
|
{
|
|
_inputField.InsertText($"@{username} ");
|
|
_inputField.SetFocus();
|
|
}
|
|
|
|
private void CopyToClipboard(string text)
|
|
{
|
|
try
|
|
{
|
|
_app.Clipboard?.TrySetClipboardData(text);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Copy to clipboard failed");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scrolls the message list to a message's first line (used by reply quote lines).
|
|
/// No-op when the message isn't in the loaded buffer.
|
|
/// </summary>
|
|
private void ScrollToMessage(Guid messageId)
|
|
{
|
|
if (_messageList.Source is not ChatListSource source)
|
|
return;
|
|
|
|
for (int i = 0; i < source.Count; i++)
|
|
{
|
|
var line = source.GetLine(i);
|
|
// Match the message's own lines, not other replies' quote lines pointing at it
|
|
if (line?.MessageId == messageId && line.JumpToMessageId is null)
|
|
{
|
|
_messageList.SelectedItem = i;
|
|
_messageList.TopItem = Math.Max(0, i - 3);
|
|
_messageList.SetFocus();
|
|
_messageList.SetNeedsDraw();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ConfirmDeleteMessage(Guid messageId)
|
|
{
|
|
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
|
if (confirm == 0)
|
|
OnDeleteMessageRequested?.Invoke(messageId);
|
|
}
|
|
|
|
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
|
{
|
|
if (_messageList.VerticalScrollBar.Value == 0)
|
|
OnLoadMoreRequested?.Invoke();
|
|
}
|
|
|
|
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
|
{
|
|
var index = _usersList.SelectedItem;
|
|
if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count)
|
|
return;
|
|
|
|
var username = _usersListSource.GetUsername(index.Value);
|
|
if (username is not null)
|
|
{
|
|
OnUserProfileRequested?.Invoke(username);
|
|
e.Handled = true;
|
|
}
|
|
}
|
|
|
|
private void OnInputKeyDown(object? sender, Key e)
|
|
{
|
|
switch (e.KeyCode)
|
|
{
|
|
case TabKey:
|
|
TryAutocompleteCommand();
|
|
break;
|
|
|
|
case KeyCode.Esc when HasPendingReplyIndicator:
|
|
OnReplyCancelRequested?.Invoke();
|
|
break;
|
|
|
|
case NewlineKey:
|
|
_inputField.InsertText("\n");
|
|
break;
|
|
|
|
case EnterKey:
|
|
if (IsCurrentChannelReadOnly)
|
|
break;
|
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
|
// Send when there's text, or when only attachments are staged (empty caption).
|
|
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
|
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
|
{
|
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
|
_inputField.Text = string.Empty;
|
|
}
|
|
break;
|
|
|
|
case AltQKey:
|
|
_app.RequestStop();
|
|
break;
|
|
|
|
case CtrlKKey:
|
|
ShowSearchDialog();
|
|
break;
|
|
|
|
case F6Key:
|
|
// Move focus into the message list so you can select a message (arrows) and
|
|
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
|
FocusMessageList();
|
|
break;
|
|
|
|
case CtrlVKey:
|
|
case CtrlYKey:
|
|
// Read-only channels can't receive text or attachments.
|
|
if (IsCurrentChannelReadOnly)
|
|
break;
|
|
// Discord-style paste priority. Copied files in the OS file manager put a file
|
|
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
|
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
|
|
// Terminals never deliver either of the first two as text, so this is the only path.
|
|
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
|
StageFiles(pastedFiles);
|
|
else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel)
|
|
&& ClipboardImage.TryGetPng(out var pastedPng))
|
|
OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng);
|
|
else
|
|
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
|
break;
|
|
|
|
case CtrlXKey:
|
|
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
|
break;
|
|
|
|
case CtrlCKey:
|
|
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
|
break;
|
|
|
|
default:
|
|
return; // not one of ours — leave e.Handled false so the key types normally
|
|
}
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures
|
|
/// (e.g. another process holding the Windows clipboard) that would otherwise
|
|
/// propagate out of the input loop and crash the app.
|
|
/// </summary>
|
|
private static void GuardedClipboardAction(Action action, string operation)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Clipboard {Operation} failed", operation);
|
|
}
|
|
}
|
|
|
|
private bool _suppressEmojiReplace;
|
|
|
|
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
|
{
|
|
if (_suppressEmojiReplace)
|
|
return;
|
|
|
|
var text = _inputField.Text;
|
|
if (string.IsNullOrEmpty(text))
|
|
return;
|
|
|
|
// A file dropped onto the terminal is delivered as its absolute path inserted into the
|
|
// input — often character by character (this Terminal.Gui build has no bracketed-paste
|
|
// coalescing). As soon as the input resolves to existing file path(s), route them
|
|
// through /send (which stages them) instead of leaving a raw path to be sent as a message.
|
|
if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles)
|
|
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
|
{
|
|
_suppressEmojiReplace = true;
|
|
try
|
|
{
|
|
_inputField.Text = string.Empty;
|
|
}
|
|
finally
|
|
{
|
|
_suppressEmojiReplace = false;
|
|
}
|
|
|
|
StageFiles(droppedFiles);
|
|
return;
|
|
}
|
|
|
|
var replaced = EmojiHelper.ReplaceEmoji(text);
|
|
if (replaced == text)
|
|
return;
|
|
|
|
// Calculate where cursor should land after replacement
|
|
var lengthDelta = replaced.Length - text.Length;
|
|
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
|
|
|
_suppressEmojiReplace = true;
|
|
try
|
|
{
|
|
_inputField.Text = replaced;
|
|
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
|
}
|
|
finally
|
|
{
|
|
_suppressEmojiReplace = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tab-complete slash commands in the input field.
|
|
/// </summary>
|
|
private void TryAutocompleteCommand()
|
|
{
|
|
var text = _inputField.Text ?? string.Empty;
|
|
if (!text.StartsWith('/') || text.Contains(' '))
|
|
return;
|
|
|
|
var matches = SlashCommands
|
|
.Where(c => c.StartsWith(text, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
if (matches.Count == 1)
|
|
{
|
|
_inputField.Text = matches[0] + " ";
|
|
}
|
|
else if (matches.Count > 1)
|
|
{
|
|
// Complete to longest common prefix
|
|
var prefix = matches[0];
|
|
foreach (var m in matches.Skip(1))
|
|
{
|
|
var len = 0;
|
|
while (len < prefix.Length && len < m.Length
|
|
&& char.ToLowerInvariant(prefix[len]) == char.ToLowerInvariant(m[len]))
|
|
len++;
|
|
prefix = prefix[..len];
|
|
}
|
|
if (prefix.Length > text.Length)
|
|
_inputField.Text = prefix;
|
|
}
|
|
|
|
// Move cursor to end after autocomplete
|
|
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the
|
|
/// next Enter sends them with any typed caption.
|
|
/// </summary>
|
|
private void StageFiles(IReadOnlyList<string> files)
|
|
{
|
|
var channel = _messageManager.CurrentChannel;
|
|
if (string.IsNullOrEmpty(channel))
|
|
return;
|
|
|
|
OnFilesStaged?.Invoke(channel, files);
|
|
}
|
|
|
|
private void OnChatViewportChanged()
|
|
{
|
|
var newWidth = _messageList.Viewport.Width;
|
|
if (newWidth > 0 && newWidth != _lastChatWidth)
|
|
{
|
|
_lastChatWidth = newWidth;
|
|
_messageManager.SetChatWidth(newWidth);
|
|
RefreshMessages();
|
|
}
|
|
}
|
|
|
|
private void OnWindowKeyDown(object? sender, Key e)
|
|
{
|
|
switch (e.KeyCode)
|
|
{
|
|
case AltQKey:
|
|
_app.RequestStop();
|
|
break;
|
|
|
|
case F2Key:
|
|
ToggleUsersPanel();
|
|
break;
|
|
|
|
case CtrlKKey:
|
|
ShowSearchDialog();
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
e.Handled = true;
|
|
}
|
|
|
|
private void ShowSearchDialog()
|
|
{
|
|
OnSearchRequested?.Invoke();
|
|
}
|
|
|
|
private void OnMessagesChanged(string channelName)
|
|
{
|
|
if (channelName == _messageManager.CurrentChannel)
|
|
RefreshMessages();
|
|
else
|
|
RefreshChannelList();
|
|
|
|
// Background-channel activity feeds the status bar's Act segment
|
|
_statusLabel.SetNeedsDraw();
|
|
}
|
|
|
|
private void OnHistoryPrepended(string channelName)
|
|
{
|
|
if (channelName != _messageManager.CurrentChannel)
|
|
return;
|
|
|
|
var messages = _messageManager.GetMessages(channelName);
|
|
if (messages is null)
|
|
return;
|
|
|
|
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
|
|
|
|
RefreshMessages();
|
|
|
|
// Scroll to the item that was at the top before the prepend so the user
|
|
// stays at their previous reading position rather than jumping to the top.
|
|
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
|
|
if (prependedCount > 0)
|
|
_messageList.SelectedItem = prependedCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
|
/// </summary>
|
|
public void SetChannels(List<ChannelDto> channels)
|
|
{
|
|
_channelNames.Clear();
|
|
_channelTopics.Clear();
|
|
_channelPublic.Clear();
|
|
_channelProtected.Clear();
|
|
_systemChannels.Clear();
|
|
foreach (var ch in channels)
|
|
{
|
|
_channelNames.Add(ch.Name);
|
|
_channelTopics[ch.Name] = ch.Topic;
|
|
_channelPublic[ch.Name] = ch.IsPublic;
|
|
if (ch.IsProtected)
|
|
_channelProtected.Add(ch.Name);
|
|
if (ch.IsSystem)
|
|
_systemChannels.Add(ch.Name);
|
|
}
|
|
RefreshChannelList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
|
/// </summary>
|
|
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null,
|
|
bool? isSystem = null)
|
|
{
|
|
if (isPublic.HasValue)
|
|
_channelPublic[channelName] = isPublic.Value;
|
|
|
|
if (isProtected.HasValue)
|
|
{
|
|
if (isProtected.Value) _channelProtected.Add(channelName);
|
|
else _channelProtected.Remove(channelName);
|
|
}
|
|
|
|
if (isSystem.HasValue)
|
|
{
|
|
if (isSystem.Value) _systemChannels.Add(channelName);
|
|
else _systemChannels.Remove(channelName);
|
|
}
|
|
|
|
if (_channelNames.Contains(channelName))
|
|
{
|
|
if (isProtected.HasValue || isSystem.HasValue)
|
|
RefreshChannelList();
|
|
return;
|
|
}
|
|
|
|
_channelNames.Add(channelName);
|
|
RefreshChannelList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a channel from the left panel list.
|
|
/// </summary>
|
|
public void RemoveChannel(string channelName)
|
|
{
|
|
_channelNames.Remove(channelName);
|
|
_channelTopics.Remove(channelName);
|
|
_channelPublic.Remove(channelName);
|
|
_channelProtected.Remove(channelName);
|
|
_systemChannels.Remove(channelName);
|
|
RefreshChannelList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the topic for a specific channel.
|
|
/// </summary>
|
|
public void SetChannelTopic(string channelName, string? topic)
|
|
{
|
|
_channelTopics[channelName] = topic;
|
|
if (channelName == _messageManager.CurrentChannel)
|
|
UpdateTopicBar();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Show an error message to the user.
|
|
/// </summary>
|
|
public void ShowError(string message)
|
|
{
|
|
MessageBox.ErrorQuery(_app, "Error", message, "OK");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the connection status displayed in the status bar.
|
|
/// </summary>
|
|
public void UpdateStatusBar(string status)
|
|
{
|
|
_connectionStatus = status;
|
|
UpdateSpinner();
|
|
_statusLabel.SetNeedsDraw();
|
|
}
|
|
|
|
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), 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 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)
|
|
{
|
|
var menuScheme = SchemeManager.GetScheme("Menu");
|
|
var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal);
|
|
var width = _statusLabel.Viewport.Width;
|
|
if (width <= 0) return;
|
|
|
|
// Resolve None background for colored segments
|
|
var bg = normalAttr.Background;
|
|
Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a;
|
|
|
|
int col = 0;
|
|
|
|
void Write(string text, Attribute attr)
|
|
{
|
|
_statusLabel.SetAttribute(Resolve(attr));
|
|
foreach (var g in GraphemeHelper.GetGraphemes(text))
|
|
{
|
|
var cols = Math.Max(g.GetColumns(), 1);
|
|
if (col + cols > width) return;
|
|
_statusLabel.Move(col, 0);
|
|
_statusLabel.AddStr(g);
|
|
col += cols;
|
|
}
|
|
}
|
|
|
|
// EchoHub branding
|
|
Write(" EchoHub", Resolve(StatusBrandAttr));
|
|
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
|
|
|
|
// 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
|
|
var currentUser = _messageManager.CurrentUser;
|
|
if (!string.IsNullOrEmpty(currentUser))
|
|
Write($" \u2502 User: {currentUser}", normalAttr);
|
|
|
|
// Channel + type
|
|
var currentChannel = _messageManager.CurrentChannel;
|
|
if (!string.IsNullOrEmpty(currentChannel))
|
|
{
|
|
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
|
var typeSuffix = isPublic ? "public" : "private";
|
|
if (_channelProtected.Contains(currentChannel))
|
|
typeSuffix += " +k";
|
|
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)
|
|
{
|
|
_statusLabel.Move(col, 0);
|
|
_statusLabel.AddStr(" ");
|
|
col++;
|
|
}
|
|
|
|
e.Cancel = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set the current user name (delegates to message manager for @mention detection).
|
|
/// </summary>
|
|
public void SetCurrentUser(string username)
|
|
{
|
|
_messageManager.SetCurrentUser(username);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the current channel name.
|
|
/// </summary>
|
|
public string CurrentChannel => _messageManager.CurrentChannel;
|
|
|
|
/// <summary>
|
|
/// Get all channel names that have message buffers (for broadcasting status changes).
|
|
/// </summary>
|
|
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
|
|
|
|
/// <summary>
|
|
/// Switch the chat view to the given channel, resetting its unread count and updating the topic bar.
|
|
/// </summary>
|
|
public void SwitchToChannel(string channelName)
|
|
{
|
|
_messageManager.CurrentChannel = channelName;
|
|
_chatFrame.Title = $"#{channelName}";
|
|
|
|
_messageManager.ClearUnread(channelName);
|
|
RefreshChannelList();
|
|
|
|
RefreshMessages();
|
|
UpdateTopicBar();
|
|
UpdateInputReadOnly();
|
|
_statusLabel.SetNeedsDraw();
|
|
|
|
// Update channel list selection
|
|
var idx = _channelNames.IndexOf(channelName);
|
|
if (idx >= 0)
|
|
_channelList.SelectedItem = idx;
|
|
}
|
|
|
|
/// <summary>Whether the active channel is read-only (a system channel like the log room).</summary>
|
|
private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel);
|
|
|
|
/// <summary>
|
|
/// Disables the input for read-only (system) channels so nothing can be typed there, and
|
|
/// reflects the state in the input frame title.
|
|
/// </summary>
|
|
private void UpdateInputReadOnly()
|
|
{
|
|
_inputField.ReadOnly = IsCurrentChannelReadOnly;
|
|
UpdateInputTitle();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear all messages and channels (used on disconnect).
|
|
/// </summary>
|
|
public void ClearAll()
|
|
{
|
|
_channelNames.Clear();
|
|
_messageManager.ClearAll();
|
|
_channelTopics.Clear();
|
|
_channelPublic.Clear();
|
|
_channelProtected.Clear();
|
|
_systemChannels.Clear();
|
|
_channelListSource.Update([], [], string.Empty);
|
|
_channelList.Source = _channelListSource;
|
|
_chatFrame.Title = "Chat";
|
|
_topicLabel.Visible = false;
|
|
_chatFrame.Y = 1;
|
|
_usersListSource.Update([]);
|
|
_usersList.Source = _usersListSource;
|
|
_usersFrame.Title = "Users";
|
|
RefreshMessages();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Focus the input field for typing.
|
|
/// </summary>
|
|
public void FocusInput()
|
|
{
|
|
_inputField.SetFocus();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the
|
|
/// most recent message when nothing is selected. No-op when the channel has no messages.
|
|
/// </summary>
|
|
private void FocusMessageList()
|
|
{
|
|
if (_messageList.Source is not ChatListSource source || source.Count == 0)
|
|
return;
|
|
|
|
if (!_messageList.SelectedItem.HasValue
|
|
|| _messageList.SelectedItem < 0
|
|
|| _messageList.SelectedItem >= source.Count)
|
|
{
|
|
_messageList.SelectedItem = source.Count - 1;
|
|
}
|
|
|
|
_messageList.SetFocus();
|
|
_messageList.SetNeedsDraw();
|
|
}
|
|
|
|
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 source = new ChatListSource();
|
|
|
|
if (width > 0)
|
|
{
|
|
foreach (var line in messages)
|
|
{
|
|
if (line.RuleLabel is not null)
|
|
source.Add(ExpandRule(line, width));
|
|
else
|
|
source.AddRange(line.Wrap(width, line.ContinuationIndent));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
source.AddRange(messages);
|
|
}
|
|
|
|
_messageList.Source = source;
|
|
if (source.Count > 0)
|
|
_messageList.SelectedItem = source.Count - 1;
|
|
}
|
|
else
|
|
{
|
|
// 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>
|
|
/// Refresh the channel list view, showing unread counts next to channel names.
|
|
/// </summary>
|
|
private void RefreshChannelList()
|
|
{
|
|
// Pin system channels (e.g. the live log room) to the very top, keeping the server's
|
|
// relative order otherwise. OrderBy is stable, so alphabetical order is preserved within
|
|
// each group. Reordering in place keeps _channelNames the source of truth for selection
|
|
// lookups. System channels are private by nature but shouldn't get the private (~) glyph,
|
|
// so exclude them from the private set.
|
|
var ordered = _channelNames.OrderBy(n => _systemChannels.Contains(n) ? 0 : 1).ToList();
|
|
_channelNames.Clear();
|
|
_channelNames.AddRange(ordered);
|
|
|
|
var privateChannels = _channelNames
|
|
.Where(n => !_systemChannels.Contains(n)
|
|
&& _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
|
|
.ToHashSet();
|
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
|
_channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels);
|
|
_channelList.Source = _channelListSource;
|
|
|
|
// Restore selection to current channel
|
|
var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
|
|
if (idx >= 0)
|
|
_channelList.SelectedItem = idx;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Show or hide the topic bar based on the current channel's topic.
|
|
/// </summary>
|
|
private void UpdateTopicBar()
|
|
{
|
|
_channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
|
|
if (!string.IsNullOrWhiteSpace(topic))
|
|
{
|
|
_topicLabel.Text = $" Topic: {topic}";
|
|
_topicLabel.Visible = true;
|
|
_chatFrame.Y = 2;
|
|
}
|
|
else
|
|
{
|
|
_topicLabel.Visible = false;
|
|
_chatFrame.Y = 1;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
|
|
/// </summary>
|
|
private void UpdateLayout()
|
|
{
|
|
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
|
|
_chatFrame.Width = Dim.Fill(rightMargin);
|
|
_topicLabel.Width = Dim.Fill(rightMargin);
|
|
_inputFrame.Width = Dim.Fill(rightMargin);
|
|
_usersFrame.Visible = _usersPanelVisible;
|
|
SetNeedsDraw();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggle the online users panel visibility (F2).
|
|
/// </summary>
|
|
public void ToggleUsersPanel()
|
|
{
|
|
_usersPanelVisible = !_usersPanelVisible;
|
|
UpdateLayout();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the online users list display.
|
|
/// </summary>
|
|
public void UpdateOnlineUsers(List<UserPresenceDto> users)
|
|
{
|
|
var displayItems = users.Select(u =>
|
|
{
|
|
var statusIcon = u.Status switch
|
|
{
|
|
UserStatus.Online => "\u25cf", // ●
|
|
UserStatus.Away => "\u25cb", // ○
|
|
UserStatus.DoNotDisturb => "\u25d0", // ◐
|
|
UserStatus.Invisible => "\u25cc", // ◌
|
|
_ => " "
|
|
};
|
|
var name = u.DisplayName ?? u.Username;
|
|
var roleTag = u.Role switch
|
|
{
|
|
ServerRole.Owner => "\u2605", // ★
|
|
ServerRole.Admin => "\u2666", // ♦
|
|
ServerRole.Mod => "\u2740", // ❀
|
|
_ => ""
|
|
};
|
|
var text = roleTag.Length > 0
|
|
? $"{statusIcon} {roleTag} {name}"
|
|
: $"{statusIcon} {name}";
|
|
// Users connected only via the IRC gateway get a tag — they lack client features
|
|
// (encryption, attachments, profiles), which is useful context in conversation.
|
|
if (u.IsIrc)
|
|
text += " [irc]";
|
|
// 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);
|
|
_usersList.Source = _usersListSource;
|
|
_usersFrame.Title = $"Users ({users.Count})";
|
|
}
|
|
|
|
// @mention — not preceded by a word char (avoids emails)
|
|
[GeneratedRegex(@"(?<!\w)@([\w-]+)")]
|
|
private static partial Regex ClickMentionRegex();
|
|
|
|
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
|
|
[GeneratedRegex(@"(?<!\w)#((?=.*[a-zA-Z])[\w-]+)")]
|
|
private static partial Regex ClickChannelRegex();
|
|
}
|