mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: add test sound command and enable notifications by default
This commit is contained in:
@@ -347,6 +347,11 @@ public sealed class AppOrchestrator : IDisposable
|
||||
await _apiClient!.NukeChannelAsync(channel);
|
||||
};
|
||||
|
||||
_commandHandler.OnTestSound += async () =>
|
||||
{
|
||||
await _notificationSound.PlayTestAsync();
|
||||
};
|
||||
|
||||
_commandHandler.OnQuit += () =>
|
||||
{
|
||||
InvokeUI(() => _app.RequestStop());
|
||||
@@ -788,7 +793,6 @@ public sealed class AppOrchestrator : IDisposable
|
||||
InvokeUI(() => _mainWindow.AddMessage(message));
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentUsername)
|
||||
&& !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)
|
||||
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_ = _notificationSound.PlayAsync();
|
||||
|
||||
@@ -25,6 +25,7 @@ public class CommandHandler
|
||||
public event Func<string, Task>? OnUnmuteUser;
|
||||
public event Func<string, string, Task>? OnAssignRole;
|
||||
public event Func<Task>? OnNukeChannel;
|
||||
public event Func<Task>? OnTestSound;
|
||||
public event Func<Task>? OnQuit;
|
||||
public event Func<Task>? OnHelp;
|
||||
|
||||
@@ -60,6 +61,7 @@ public class CommandHandler
|
||||
"unmute" => await HandleUnmute(args),
|
||||
"role" => await HandleRole(args),
|
||||
"nuke" => await HandleNuke(),
|
||||
"test-sound" => await HandleTestSound(),
|
||||
"quit" or "exit" => await HandleQuit(),
|
||||
"help" or "?" => await HandleHelp(),
|
||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||
@@ -319,6 +321,13 @@ public class CommandHandler
|
||||
return new CommandResult(true, "Nuking channel history...");
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleTestSound()
|
||||
{
|
||||
if (OnTestSound is not null)
|
||||
await OnTestSound();
|
||||
return new CommandResult(true, "Playing notification sound...");
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleHelp()
|
||||
{
|
||||
if (OnHelp is not null)
|
||||
@@ -346,6 +355,7 @@ public class CommandHandler
|
||||
/unmute <user> - Unmute a user (Mod+)
|
||||
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||
/nuke - Clear channel history (Mod+)
|
||||
/test-sound - Play notification sound
|
||||
/quit - Exit the app
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class ClientConfig
|
||||
|
||||
public class NotificationConfig
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public byte Volume { get; set; } = 30;
|
||||
public string? SoundFile { get; set; }
|
||||
}
|
||||
|
||||
@@ -25,13 +25,29 @@ public class NotificationSoundService
|
||||
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||
return;
|
||||
|
||||
await PlayInternal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
|
||||
/// </summary>
|
||||
public async Task PlayTestAsync()
|
||||
{
|
||||
if (_resolvedSoundPath is null)
|
||||
return;
|
||||
|
||||
await PlayInternal();
|
||||
}
|
||||
|
||||
private async Task PlayInternal()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
await _player.Stop();
|
||||
|
||||
await _player.SetVolume(_config.Volume);
|
||||
await _player.Play(_resolvedSoundPath);
|
||||
await _player.Play(_resolvedSoundPath!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class MainWindow : Runnable
|
||||
"/status", "/nick", "/color", "/theme", "/send",
|
||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||
"/mute", "/unmute", "/role", "/nuke", "/quit", "/help"
|
||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||
];
|
||||
|
||||
private readonly List<string> _channelNames = [];
|
||||
@@ -923,8 +923,9 @@ public sealed class MainWindow : Runnable
|
||||
// Render link embeds if present
|
||||
if (message.Embeds is { Count: > 0 })
|
||||
{
|
||||
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
|
||||
foreach (var embed in message.Embeds)
|
||||
lines.AddRange(FormatEmbed(embed, indent));
|
||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -977,115 +978,45 @@ public sealed class MainWindow : Runnable
|
||||
|
||||
/// <summary>
|
||||
/// Format a link embed as indented chat lines with a left border bar,
|
||||
/// optional description wrapping, and a small icon on the right (Discord-style).
|
||||
/// Layout: ▏ {text column} {icon column}
|
||||
/// text at full width, and optional icon below (preview image).
|
||||
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
|
||||
/// </summary>
|
||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent)
|
||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||
{
|
||||
var lines = new List<ChatLine>();
|
||||
const string border = "\u258f "; // ▏ + space
|
||||
const int borderCols = 2; // ▏ = 1 col + space = 1 col
|
||||
const int iconGap = 1; // space between text and icon
|
||||
const int borderCols = 2;
|
||||
int indentCols = indent.GetColumns();
|
||||
int textWidth = chatWidth - indentCols - borderCols;
|
||||
if (textWidth < 20) textWidth = 20;
|
||||
|
||||
// Parse icon lines if present
|
||||
var iconLines = new List<string>();
|
||||
int iconWidth = 0;
|
||||
if (!string.IsNullOrWhiteSpace(embed.ImageAscii))
|
||||
// Helper: create a bordered text line
|
||||
void AddTextLine(string text, Attribute? color)
|
||||
{
|
||||
foreach (var artLine in embed.ImageAscii.Split('\n'))
|
||||
{
|
||||
var trimmed = artLine.TrimEnd('\r');
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
iconLines.Add(trimmed);
|
||||
}
|
||||
if (iconLines.Count > 0)
|
||||
{
|
||||
// Measure icon width from the first line (strip color tags for measurement)
|
||||
var stripped = ChatLine.StripColorTags(iconLines[0]);
|
||||
iconWidth = stripped.GetColumns();
|
||||
}
|
||||
lines.Add(new ChatLine(
|
||||
[
|
||||
new ChatSegment(indent, null),
|
||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||
new ChatSegment(text, color)
|
||||
]));
|
||||
}
|
||||
|
||||
bool hasIcon = iconLines.Count > 0 && iconWidth > 0;
|
||||
int indentCols = indent.GetColumns();
|
||||
|
||||
// We don't know the terminal width at format time, so use a reasonable default
|
||||
// for text wrapping. The ChatListSource.Render will handle final clipping.
|
||||
const int estimatedWidth = 80;
|
||||
int availableForText = estimatedWidth - indentCols - borderCols;
|
||||
int textColWidth = hasIcon
|
||||
? availableForText - iconWidth - iconGap
|
||||
: availableForText;
|
||||
if (textColWidth < 20) textColWidth = 20;
|
||||
|
||||
// Collect all text rows (site name, title, wrapped description, URL)
|
||||
var textRows = new List<(string Text, Attribute? Color)>();
|
||||
|
||||
// Site name
|
||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||
textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr));
|
||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||
|
||||
// Title
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
textRows.Add((embed.Title, ChatColors.EmbedTitleAttr));
|
||||
{
|
||||
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
||||
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
||||
}
|
||||
|
||||
// Word-wrap description
|
||||
// Description (word-wrapped at full available width)
|
||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
foreach (var wrappedLine in WordWrap(embed.Description, textColWidth))
|
||||
textRows.Add((wrappedLine, ChatColors.EmbedDescAttr));
|
||||
}
|
||||
|
||||
// Dim URL at the bottom
|
||||
textRows.Add((embed.Url, ChatColors.EmbedUrlAttr));
|
||||
|
||||
// Merge text rows with icon rows side-by-side
|
||||
int totalLines = Math.Max(textRows.Count, iconLines.Count);
|
||||
for (int i = 0; i < totalLines; i++)
|
||||
{
|
||||
var segments = new List<ChatSegment>();
|
||||
segments.Add(new ChatSegment(indent, null));
|
||||
segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr));
|
||||
|
||||
if (i < textRows.Count)
|
||||
{
|
||||
var (text, color) = textRows[i];
|
||||
segments.Add(new ChatSegment(text, color));
|
||||
|
||||
// Pad to align icon column
|
||||
if (hasIcon && i < iconLines.Count)
|
||||
{
|
||||
int textCols = text.GetColumns();
|
||||
int padding = textColWidth - textCols + iconGap;
|
||||
if (padding > 0)
|
||||
segments.Add(new ChatSegment(new string(' ', padding), null));
|
||||
}
|
||||
}
|
||||
else if (hasIcon && i < iconLines.Count)
|
||||
{
|
||||
// No text row, pad the full text column + gap
|
||||
segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null));
|
||||
}
|
||||
|
||||
// Append icon line
|
||||
if (hasIcon && i < iconLines.Count)
|
||||
{
|
||||
var iconLine = iconLines[i];
|
||||
if (ChatLine.HasColorTags(iconLine))
|
||||
{
|
||||
// Build a composite: plain segments + colored icon
|
||||
var plainPart = new ChatLine(segments);
|
||||
var iconPart = ChatLine.FromColoredText(iconLine);
|
||||
var merged = new List<ChatSegment>(plainPart.Segments);
|
||||
merged.AddRange(iconPart.Segments);
|
||||
lines.Add(new ChatLine(merged));
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
segments.Add(new ChatSegment(iconLine, null));
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(new ChatLine(segments));
|
||||
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
||||
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
Reference in New Issue
Block a user