mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: Enhance chat UI with topic display and command autocomplete functionality
This commit is contained in:
@@ -73,86 +73,88 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [lint-markdown, detect-changes, test]
|
||||
if: needs.detect-changes.outputs.src_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if release exists
|
||||
id: check_release
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Server win-x64
|
||||
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 linux-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 osx-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 Client win-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 linux-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 osx-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: Zip artifacts
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
run: |
|
||||
cd publish
|
||||
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
|
||||
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
|
||||
zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
|
||||
zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
|
||||
zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
|
||||
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_release.outputs.exists == 'false'
|
||||
run: |
|
||||
gh release create "${{ steps.version.outputs.tag }}" \
|
||||
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
||||
--generate-notes \
|
||||
EchoHub-Server-win-x64.zip \
|
||||
EchoHub-Server-linux-x64.zip \
|
||||
EchoHub-Server-osx-x64.zip \
|
||||
EchoHub-Client-win-x64.zip \
|
||||
EchoHub-Client-linux-x64.zip \
|
||||
EchoHub-Client-osx-x64.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# NOTE: Release job disabled until workflows are verified.
|
||||
# Uncomment to enable automatic releases with tags.
|
||||
# release:
|
||||
# name: Create Release
|
||||
# needs: [lint-markdown, detect-changes, test]
|
||||
# if: needs.detect-changes.outputs.src_changed == 'true'
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Setup .NET 10
|
||||
# uses: actions/setup-dotnet@v4
|
||||
# with:
|
||||
# dotnet-version: '10.0.x'
|
||||
#
|
||||
# - name: Read version
|
||||
# id: version
|
||||
# run: |
|
||||
# VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
# echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
# echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
#
|
||||
# - name: Check if release exists
|
||||
# id: check_release
|
||||
# run: |
|
||||
# if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||
# echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
# else
|
||||
# echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
# fi
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Publish Server win-x64
|
||||
# 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 linux-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 osx-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 Client win-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 linux-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 osx-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: Zip artifacts
|
||||
# if: steps.check_release.outputs.exists == 'false'
|
||||
# run: |
|
||||
# cd publish
|
||||
# zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
|
||||
# zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/
|
||||
# zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/
|
||||
# zip -r ../EchoHub-Client-win-x64.zip client-win-x64/
|
||||
# zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/
|
||||
# zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
|
||||
#
|
||||
# - name: Create GitHub Release
|
||||
# if: steps.check_release.outputs.exists == 'false'
|
||||
# run: |
|
||||
# gh release create "${{ steps.version.outputs.tag }}" \
|
||||
# --title "EchoHub ${{ steps.version.outputs.tag }}" \
|
||||
# --generate-notes \
|
||||
# EchoHub-Server-win-x64.zip \
|
||||
# EchoHub-Server-linux-x64.zip \
|
||||
# EchoHub-Server-osx-x64.zip \
|
||||
# EchoHub-Client-win-x64.zip \
|
||||
# EchoHub-Client-linux-x64.zip \
|
||||
# EchoHub-Client-osx-x64.zip
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docs:
|
||||
name: Build & Deploy Docs
|
||||
|
||||
@@ -203,7 +203,11 @@ public sealed class AppOrchestrator : IDisposable
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -190,6 +190,15 @@ public class ChatListSource : IListDataSource
|
||||
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>
|
||||
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
||||
/// </summary>
|
||||
|
||||
@@ -22,15 +22,27 @@ public sealed class MainWindow : Runnable
|
||||
private readonly TextView _inputField;
|
||||
private readonly FrameView _chatFrame;
|
||||
private readonly Label _statusLabel;
|
||||
private readonly Label _topicLabel;
|
||||
private MenuBar _menuBar;
|
||||
|
||||
// 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 AltEnterKey = Key.Enter.WithAlt;
|
||||
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 Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||
private readonly Dictionary<string, int> _channelUnread = [];
|
||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||
private string _currentChannel = string.Empty;
|
||||
private string _currentUser = string.Empty;
|
||||
|
||||
@@ -110,12 +122,24 @@ public sealed class MainWindow : Runnable
|
||||
channelsFrame.Add(_channelList);
|
||||
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
|
||||
_chatFrame = new FrameView
|
||||
{
|
||||
Title = "Chat",
|
||||
X = 25,
|
||||
Y = 1, // below menu bar
|
||||
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
||||
Width = Dim.Fill(),
|
||||
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||
};
|
||||
@@ -134,7 +158,7 @@ public sealed class MainWindow : Runnable
|
||||
// Bottom input area
|
||||
var inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message (Enter=send, Alt+Enter=newline)",
|
||||
Title = "Message (Enter=send, Tab=autocomplete)",
|
||||
X = 25,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(),
|
||||
@@ -168,7 +192,7 @@ public sealed class MainWindow : Runnable
|
||||
// Apply our custom color schemes to all views
|
||||
ApplyColorSchemes();
|
||||
|
||||
// Window-level key handling for Ctrl+S (send) and Ctrl+C (quit)
|
||||
// Window-level key handling for Ctrl+C (quit)
|
||||
KeyDown += OnWindowKeyDown;
|
||||
}
|
||||
|
||||
@@ -188,7 +212,7 @@ public sealed class MainWindow : Runnable
|
||||
// Propagate to all child views that should use the base scheme
|
||||
foreach (var sub in SubViews)
|
||||
{
|
||||
if (sub != _menuBar && sub != _statusLabel)
|
||||
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
|
||||
sub.SetScheme(baseScheme);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +221,7 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
_menuBar.SetScheme(menuScheme);
|
||||
_statusLabel.SetScheme(menuScheme);
|
||||
_topicLabel.SetScheme(menuScheme);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +307,12 @@ public sealed class MainWindow : Runnable
|
||||
|
||||
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");
|
||||
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)
|
||||
{
|
||||
// Ctrl+C quits from anywhere
|
||||
@@ -316,6 +380,7 @@ public sealed class MainWindow : Runnable
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public void AddMessage(MessageDto message)
|
||||
{
|
||||
@@ -335,20 +400,33 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Increment unread count for non-active channels
|
||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||
_channelUnread[message.ChannelName] = count + 1;
|
||||
RefreshChannelList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a system/informational message to a channel.
|
||||
/// Add a system/informational message to a channel with colored styling.
|
||||
/// </summary>
|
||||
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))
|
||||
{
|
||||
messages = [];
|
||||
_channelMessages[channelName] = messages;
|
||||
}
|
||||
messages.Add(new ChatLine(formatted));
|
||||
messages.Add(new ChatLine(segments));
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
{
|
||||
@@ -357,17 +435,23 @@ public sealed class MainWindow : Runnable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a status change message to a channel.
|
||||
/// Add a status change message to a channel with colored styling.
|
||||
/// </summary>
|
||||
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))
|
||||
{
|
||||
messages = [];
|
||||
_channelMessages[channelName] = messages;
|
||||
}
|
||||
messages.Add(new ChatLine(formatted));
|
||||
messages.Add(new ChatLine(segments));
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
{
|
||||
@@ -376,18 +460,30 @@ public sealed class MainWindow : Runnable
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public void SetChannels(List<ChannelDto> channels)
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelTopics.Clear();
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_channelNames.Add(ch.Name);
|
||||
_channelTopics[ch.Name] = ch.Topic;
|
||||
if (!_channelMessages.ContainsKey(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>
|
||||
@@ -427,13 +523,19 @@ public sealed class MainWindow : Runnable
|
||||
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
|
||||
|
||||
/// <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>
|
||||
public void SwitchToChannel(string channelName)
|
||||
{
|
||||
_currentChannel = channelName;
|
||||
_chatFrame.Title = $"#{channelName}";
|
||||
|
||||
// Reset unread count for this channel
|
||||
_channelUnread[channelName] = 0;
|
||||
RefreshChannelList();
|
||||
|
||||
RefreshMessages();
|
||||
UpdateTopicBar();
|
||||
|
||||
// Update channel list selection
|
||||
var idx = _channelNames.IndexOf(channelName);
|
||||
@@ -469,10 +571,14 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelMessages.Clear();
|
||||
_channelUnread.Clear();
|
||||
_channelTopics.Clear();
|
||||
_currentChannel = string.Empty;
|
||||
_currentUser = string.Empty;
|
||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||
_chatFrame.Title = "Chat";
|
||||
_topicLabel.Visible = false;
|
||||
_chatFrame.Y = 1;
|
||||
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>
|
||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||
/// Timestamps are dimmed and sender names are colored.
|
||||
/// </summary>
|
||||
private static List<ChatLine> FormatMessage(MessageDto message)
|
||||
{
|
||||
@@ -514,7 +659,7 @@ public sealed class MainWindow : Runnable
|
||||
switch (message.Type)
|
||||
{
|
||||
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
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
@@ -533,16 +678,15 @@ public sealed class MainWindow : Runnable
|
||||
case MessageType.File:
|
||||
var fileName = message.AttachmentFileName ?? "unknown";
|
||||
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;
|
||||
|
||||
case MessageType.Text:
|
||||
default:
|
||||
var contentLines = message.Content.Split('\n');
|
||||
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
|
||||
// Prefix is: [HH:mm] + space + senderName + space
|
||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
{
|
||||
@@ -555,17 +699,14 @@ public sealed class MainWindow : Runnable
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
{
|
||||
new(prefix, null),
|
||||
new(senderName, senderColor.Value),
|
||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||
new(senderName, senderColor),
|
||||
new(suffix, null)
|
||||
};
|
||||
return new ChatLine(segments);
|
||||
|
||||
Reference in New Issue
Block a user