feat: Enhance chat UI with topic display and command autocomplete functionality

This commit is contained in:
HueByte
2026-02-19 05:50:24 +01:00
parent 0c2a94ef17
commit b45b4c3139
4 changed files with 262 additions and 106 deletions
+82 -80
View File
@@ -73,86 +73,88 @@ jobs:
- name: Test - name: Test
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
release: # NOTE: Release job disabled until workflows are verified.
name: Create Release # Uncomment to enable automatic releases with tags.
needs: [lint-markdown, detect-changes, test] # release:
if: needs.detect-changes.outputs.src_changed == 'true' # name: Create Release
runs-on: ubuntu-latest # needs: [lint-markdown, detect-changes, test]
steps: # if: needs.detect-changes.outputs.src_changed == 'true'
- uses: actions/checkout@v4 # runs-on: ubuntu-latest
# steps:
- name: Setup .NET 10 # - uses: actions/checkout@v4
uses: actions/setup-dotnet@v4 #
with: # - name: Setup .NET 10
dotnet-version: '10.0.x' # uses: actions/setup-dotnet@v4
# with:
- name: Read version # dotnet-version: '10.0.x'
id: version #
run: | # - name: Read version
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props) # id: version
echo "version=$VERSION" >> "$GITHUB_OUTPUT" # run: |
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" # VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
# echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if release exists # echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
id: check_release #
run: | # - name: Check if release exists
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then # id: check_release
echo "exists=true" >> "$GITHUB_OUTPUT" # run: |
else # if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
echo "exists=false" >> "$GITHUB_OUTPUT" # echo "exists=true" >> "$GITHUB_OUTPUT"
fi # else
env: # echo "exists=false" >> "$GITHUB_OUTPUT"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # fi
# env:
- name: Publish Server win-x64 # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64 # - name: Publish Server win-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Publish Server linux-x64 # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64 # - name: Publish Server linux-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Publish Server osx-x64 # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64 # - name: Publish Server osx-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Publish Client win-x64 # run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 # - name: Publish Client win-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Publish Client linux-x64 # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64 # - name: Publish Client linux-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Publish Client osx-x64 # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
if: steps.check_release.outputs.exists == 'false' #
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64 # - name: Publish Client osx-x64
# if: steps.check_release.outputs.exists == 'false'
- name: Zip artifacts # run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
if: steps.check_release.outputs.exists == 'false' #
run: | # - name: Zip artifacts
cd publish # if: steps.check_release.outputs.exists == 'false'
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/ # run: |
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ # cd publish
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ # zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ # zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ # zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ # zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
# zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
- name: Create GitHub Release # zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
if: steps.check_release.outputs.exists == 'false' #
run: | # - name: Create GitHub Release
gh release create "${{ steps.version.outputs.tag }}" \ # if: steps.check_release.outputs.exists == 'false'
--title "EchoHub ${{ steps.version.outputs.tag }}" \ # run: |
--generate-notes \ # gh release create "${{ steps.version.outputs.tag }}" \
EchoHub-Server-win-x64.zip \ # --title "EchoHub ${{ steps.version.outputs.tag }}" \
EchoHub-Server-linux-x64.zip \ # --generate-notes \
EchoHub-Server-osx-x64.zip \ # EchoHub-Server-win-x64.zip \
EchoHub-Client-win-x64.zip \ # EchoHub-Server-linux-x64.zip \
EchoHub-Client-linux-x64.zip \ # EchoHub-Server-osx-x64.zip \
EchoHub-Client-osx-x64.zip # EchoHub-Client-win-x64.zip \
env: # EchoHub-Client-linux-x64.zip \
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # EchoHub-Client-osx-x64.zip
# env:
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docs: docs:
name: Build & Deploy Docs name: Build & Deploy Docs
+5 -1
View File
@@ -203,7 +203,11 @@ public sealed class AppOrchestrator : IDisposable
try try
{ {
await _apiClient!.UpdateChannelTopicAsync(channel, topic); await _apiClient!.UpdateChannelTopicAsync(channel, topic);
InvokeUI(() => _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}")); InvokeUI(() =>
{
_mainWindow.SetChannelTopic(channel, topic);
_mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}");
});
} }
catch (Exception ex) catch (Exception ex)
{ {
+9
View File
@@ -190,6 +190,15 @@ public class ChatListSource : IListDataSource
public void Dispose() { } public void Dispose() { }
} }
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary> /// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes. /// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary> /// </summary>
+166 -25
View File
@@ -22,15 +22,27 @@ public sealed class MainWindow : Runnable
private readonly TextView _inputField; private readonly TextView _inputField;
private readonly FrameView _chatFrame; private readonly FrameView _chatFrame;
private readonly Label _statusLabel; private readonly Label _statusLabel;
private readonly Label _topicLabel;
private MenuBar _menuBar; private MenuBar _menuBar;
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled) // Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
private static readonly Key EnterKey = Key.Enter; private static readonly Key EnterKey = Key.Enter;
private static readonly Key AltEnterKey = Key.Enter.WithAlt; private static readonly Key AltEnterKey = Key.Enter.WithAlt;
private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key CtrlCKey = Key.C.WithCtrl;
private static readonly Key TabKey = Key.Tab;
// Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands =
[
"/status", "/nick", "/color", "/theme", "/send",
"/profile", "/servers", "/join", "/leave", "/topic",
"/users", "/quit", "/help"
];
private readonly List<string> _channelNames = []; private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<ChatLine>> _channelMessages = []; private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = [];
private string _currentChannel = string.Empty; private string _currentChannel = string.Empty;
private string _currentUser = string.Empty; private string _currentUser = string.Empty;
@@ -110,12 +122,24 @@ public sealed class MainWindow : Runnable
channelsFrame.Add(_channelList); channelsFrame.Add(_channelList);
Add(channelsFrame); Add(channelsFrame);
// Topic bar — sits above the chat frame in the right column
_topicLabel = new Label
{
Text = "",
X = 25,
Y = 1,
Width = Dim.Fill(),
Height = 1,
Visible = false
};
Add(_topicLabel);
// Center panel - messages // Center panel - messages
_chatFrame = new FrameView _chatFrame = new FrameView
{ {
Title = "Chat", Title = "Chat",
X = 25, X = 25,
Y = 1, // below menu bar Y = 1, // below menu bar (shifts to 2 when topic is visible)
Width = Dim.Fill(), Width = Dim.Fill(),
Height = Dim.Fill(6) // leave room for input area and status bar Height = Dim.Fill(6) // leave room for input area and status bar
}; };
@@ -134,7 +158,7 @@ public sealed class MainWindow : Runnable
// Bottom input area // Bottom input area
var inputFrame = new FrameView var inputFrame = new FrameView
{ {
Title = "Message (Enter=send, Alt+Enter=newline)", Title = "Message (Enter=send, Tab=autocomplete)",
X = 25, X = 25,
Y = Pos.Bottom(_chatFrame), Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(), Width = Dim.Fill(),
@@ -168,7 +192,7 @@ public sealed class MainWindow : Runnable
// Apply our custom color schemes to all views // Apply our custom color schemes to all views
ApplyColorSchemes(); ApplyColorSchemes();
// Window-level key handling for Ctrl+S (send) and Ctrl+C (quit) // Window-level key handling for Ctrl+C (quit)
KeyDown += OnWindowKeyDown; KeyDown += OnWindowKeyDown;
} }
@@ -188,7 +212,7 @@ public sealed class MainWindow : Runnable
// Propagate to all child views that should use the base scheme // Propagate to all child views that should use the base scheme
foreach (var sub in SubViews) foreach (var sub in SubViews)
{ {
if (sub != _menuBar && sub != _statusLabel) if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
sub.SetScheme(baseScheme); sub.SetScheme(baseScheme);
} }
} }
@@ -197,6 +221,7 @@ public sealed class MainWindow : Runnable
{ {
_menuBar.SetScheme(menuScheme); _menuBar.SetScheme(menuScheme);
_statusLabel.SetScheme(menuScheme); _statusLabel.SetScheme(menuScheme);
_topicLabel.SetScheme(menuScheme);
} }
} }
@@ -282,7 +307,12 @@ public sealed class MainWindow : Runnable
private void OnInputKeyDown(object? sender, Key e) private void OnInputKeyDown(object? sender, Key e)
{ {
if (e.KeyCode == AltEnterKey.KeyCode) if (e.KeyCode == TabKey.KeyCode)
{
TryAutocompleteCommand();
e.Handled = true;
}
else if (e.KeyCode == AltEnterKey.KeyCode)
{ {
_inputField.InsertText("\n"); _inputField.InsertText("\n");
e.Handled = true; e.Handled = true;
@@ -304,6 +334,40 @@ public sealed class MainWindow : Runnable
} }
} }
/// <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;
}
}
private void OnWindowKeyDown(object? sender, Key e) private void OnWindowKeyDown(object? sender, Key e)
{ {
// Ctrl+C quits from anywhere // Ctrl+C quits from anywhere
@@ -316,6 +380,7 @@ public sealed class MainWindow : Runnable
/// <summary> /// <summary>
/// Add a message to the specified channel's message list and refresh if it is the current channel. /// Add a message to the specified channel's message list and refresh if it is the current channel.
/// Tracks unread count for non-active channels.
/// </summary> /// </summary>
public void AddMessage(MessageDto message) public void AddMessage(MessageDto message)
{ {
@@ -335,20 +400,33 @@ public sealed class MainWindow : Runnable
{ {
RefreshMessages(); RefreshMessages();
} }
else
{
// Increment unread count for non-active channels
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
RefreshChannelList();
}
} }
/// <summary> /// <summary>
/// Add a system/informational message to a channel. /// Add a system/informational message to a channel with colored styling.
/// </summary> /// </summary>
public void AddSystemMessage(string channelName, string text) public void AddSystemMessage(string channelName, string text)
{ {
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}"; var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {text}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages)) if (!_channelMessages.TryGetValue(channelName, out var messages))
{ {
messages = []; messages = [];
_channelMessages[channelName] = messages; _channelMessages[channelName] = messages;
} }
messages.Add(new ChatLine(formatted)); messages.Add(new ChatLine(segments));
if (channelName == _currentChannel) if (channelName == _currentChannel)
{ {
@@ -357,17 +435,23 @@ public sealed class MainWindow : Runnable
} }
/// <summary> /// <summary>
/// Add a status change message to a channel. /// Add a status change message to a channel with colored styling.
/// </summary> /// </summary>
public void AddStatusMessage(string channelName, string username, string status) public void AddStatusMessage(string channelName, string username, string status)
{ {
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}"; var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {username} is now {status}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages)) if (!_channelMessages.TryGetValue(channelName, out var messages))
{ {
messages = []; messages = [];
_channelMessages[channelName] = messages; _channelMessages[channelName] = messages;
} }
messages.Add(new ChatLine(formatted)); messages.Add(new ChatLine(segments));
if (channelName == _currentChannel) if (channelName == _currentChannel)
{ {
@@ -376,18 +460,30 @@ public sealed class MainWindow : Runnable
} }
/// <summary> /// <summary>
/// Set the list of available channels and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary> /// </summary>
public void SetChannels(List<ChannelDto> channels) public void SetChannels(List<ChannelDto> channels)
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelTopics.Clear();
foreach (var ch in channels) foreach (var ch in channels)
{ {
_channelNames.Add(ch.Name); _channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic;
if (!_channelMessages.ContainsKey(ch.Name)) if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = []; _channelMessages[ch.Name] = [];
} }
_channelList.SetSource(new ObservableCollection<string>(_channelNames)); RefreshChannelList();
}
/// <summary>
/// Update the topic for a specific channel.
/// </summary>
public void SetChannelTopic(string channelName, string? topic)
{
_channelTopics[channelName] = topic;
if (channelName == _currentChannel)
UpdateTopicBar();
} }
/// <summary> /// <summary>
@@ -427,13 +523,19 @@ public sealed class MainWindow : Runnable
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly(); public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
/// <summary> /// <summary>
/// Switch the chat view to the given channel. /// Switch the chat view to the given channel, resetting its unread count and updating the topic bar.
/// </summary> /// </summary>
public void SwitchToChannel(string channelName) public void SwitchToChannel(string channelName)
{ {
_currentChannel = channelName; _currentChannel = channelName;
_chatFrame.Title = $"#{channelName}"; _chatFrame.Title = $"#{channelName}";
// Reset unread count for this channel
_channelUnread[channelName] = 0;
RefreshChannelList();
RefreshMessages(); RefreshMessages();
UpdateTopicBar();
// Update channel list selection // Update channel list selection
var idx = _channelNames.IndexOf(channelName); var idx = _channelNames.IndexOf(channelName);
@@ -469,10 +571,14 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelMessages.Clear(); _channelMessages.Clear();
_channelUnread.Clear();
_channelTopics.Clear();
_currentChannel = string.Empty; _currentChannel = string.Empty;
_currentUser = string.Empty; _currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames)); _channelList.SetSource(new ObservableCollection<string>(_channelNames));
_chatFrame.Title = "Chat"; _chatFrame.Title = "Chat";
_topicLabel.Visible = false;
_chatFrame.Y = 1;
RefreshMessages(); RefreshMessages();
} }
@@ -500,8 +606,47 @@ public sealed class MainWindow : Runnable
} }
} }
/// <summary>
/// Refresh the channel list view, showing unread counts next to channel names.
/// </summary>
private void RefreshChannelList()
{
var displayNames = _channelNames.Select(name =>
{
_channelUnread.TryGetValue(name, out var unread);
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
}).ToList();
_channelList.SetSource(new ObservableCollection<string>(displayNames));
// Restore selection to current channel
var idx = _channelNames.IndexOf(_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(_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> /// <summary>
/// Format a message DTO into one or more display lines based on its MessageType. /// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored.
/// </summary> /// </summary>
private static List<ChatLine> FormatMessage(MessageDto message) private static List<ChatLine> FormatMessage(MessageDto message)
{ {
@@ -514,7 +659,7 @@ public sealed class MainWindow : Runnable
switch (message.Type) switch (message.Type)
{ {
case MessageType.Image: case MessageType.Image:
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, " [Image]")); lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
// Content IS the ASCII art — add each line as a separate list item // Content IS the ASCII art — add each line as a separate list item
if (!string.IsNullOrWhiteSpace(message.Content)) if (!string.IsNullOrWhiteSpace(message.Content))
{ {
@@ -533,16 +678,15 @@ public sealed class MainWindow : Runnable
case MessageType.File: case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown"; var fileName = message.AttachmentFileName ?? "unknown";
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" [File: {fileName}]{fileContent}")); lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
break; break;
case MessageType.Text: case MessageType.Text:
default: default:
var contentLines = message.Content.Split('\n'); var contentLines = message.Content.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r'); var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" {firstLine}")); lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}"));
// Continuation lines indented to align with first line's content // Continuation lines indented to align with first line's content
// Prefix is: [HH:mm] + space + senderName + space
var indent = new string(' ', $"[{time}] {senderName} ".Length); var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++) for (int i = 1; i < contentLines.Length; i++)
{ {
@@ -555,17 +699,14 @@ public sealed class MainWindow : Runnable
} }
/// <summary> /// <summary>
/// Build a chat line with an optionally colored sender name. /// Build a chat line with a dimmed timestamp and optionally colored sender name.
/// </summary> /// </summary>
private static ChatLine BuildChatLine(string prefix, string senderName, Attribute? senderColor, string suffix) private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
{ {
if (senderColor is null)
return new ChatLine($"{prefix}{senderName}{suffix}");
var segments = new List<ChatSegment> var segments = new List<ChatSegment>
{ {
new(prefix, null), new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor.Value), new(senderName, senderColor),
new(suffix, null) new(suffix, null)
}; };
return new ChatLine(segments); return new ChatLine(segments);