mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: add password protection for channels
- Updated IChatService to include password parameter in JoinChannelAsync method. - Modified ChannelDto and related models to support password functionality. - Implemented password handling in ChannelService for channel creation and membership validation. - Enhanced IrcCommandHandler to manage channel join requests with passwords. - Added ChannelPasswordDialog for user input when joining protected channels. - Created database migration to add PasswordHash column to Channels table. - Updated tests to cover new password functionality in channel joining and management.
This commit is contained in:
@@ -216,13 +216,15 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleCmdJoinChannel(string channelName)
|
||||
private async Task HandleCmdJoinChannel(string channelName, string? password)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
var history = await _conn.JoinChannelAsync(channelName);
|
||||
var history = await JoinChannelWithPasswordPromptAsync(channelName, password);
|
||||
if (history is null) return; // user cancelled the password prompt
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.EnsureChannelInList(channelName);
|
||||
@@ -237,6 +239,31 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins a channel, prompting for a password when the server requires one and
|
||||
/// re-prompting on a wrong password. Returns the channel history, or null if
|
||||
/// the user cancelled the prompt.
|
||||
/// </summary>
|
||||
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _conn.JoinChannelAsync(channelName, password);
|
||||
}
|
||||
catch (ChannelPasswordRequiredException ex)
|
||||
{
|
||||
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var message = password is not null ? ex.Message : null;
|
||||
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, message)));
|
||||
|
||||
password = await prompt.Task;
|
||||
if (password is null) return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCmdLeaveChannel()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -554,7 +581,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
InvokeUI(() =>
|
||||
{
|
||||
if (channel.IsPublic)
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||
});
|
||||
};
|
||||
@@ -707,7 +734,16 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
if (_conn.TrackChannel(channelName))
|
||||
await _conn.JoinChannelAsync(channelName);
|
||||
{
|
||||
var joined = await JoinChannelWithPasswordPromptAsync(channelName, null);
|
||||
if (joined is null)
|
||||
{
|
||||
// User cancelled the password prompt — back to the default channel
|
||||
_conn.UntrackChannel(channelName);
|
||||
InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -983,14 +1019,14 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
|
||||
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password);
|
||||
if (channel is null) return;
|
||||
|
||||
var history = await _conn.JoinChannelAsync(channel.Name);
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.EnsureChannelInList(channel.Name);
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic, channel.IsProtected);
|
||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||
_mainWindow.SwitchToChannel(channel.Name);
|
||||
if (history.Count > 0)
|
||||
|
||||
@@ -13,7 +13,7 @@ public class CommandHandler
|
||||
public event Func<string, string?, Task>? OnSendFile;
|
||||
public event Func<string?, Task>? OnOpenProfile;
|
||||
public event Func<Task>? OnOpenServers;
|
||||
public event Func<string, Task>? OnJoinChannel;
|
||||
public event Func<string, string?, Task>? OnJoinChannel;
|
||||
public event Func<Task>? OnLeaveChannel;
|
||||
public event Func<string, Task>? OnSetTopic;
|
||||
public event Func<Task>? OnListUsers;
|
||||
@@ -126,7 +126,7 @@ public class CommandHandler
|
||||
private async Task<CommandResult> HandleTheme(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
|
||||
return new CommandResult(true, "Usage: /theme <name> — pick one from the User menu's theme list (e.g. Default, Transparent, TransparentLight, Hacker)", IsError: true);
|
||||
|
||||
if (OnSetTheme is not null)
|
||||
await OnSetTheme(args.Trim());
|
||||
@@ -193,11 +193,14 @@ public class CommandHandler
|
||||
private async Task<CommandResult> HandleJoin(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
|
||||
return new CommandResult(true, "Usage: /join <channel> [password]", IsError: true);
|
||||
|
||||
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var channel = parts[0].TrimStart('#');
|
||||
var password = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : null;
|
||||
|
||||
var channel = args.Trim().TrimStart('#');
|
||||
if (OnJoinChannel is not null)
|
||||
await OnJoinChannel(channel);
|
||||
await OnJoinChannel(channel, password);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
@@ -343,7 +346,7 @@ public class CommandHandler
|
||||
/avatar <URL or filepath> - Set your avatar
|
||||
/profile [username] - View a profile
|
||||
/servers - Open saved servers
|
||||
/join <channel> - Join a channel
|
||||
/join <channel> [password] - Join a channel (password if protected)
|
||||
/leave - Leave current channel
|
||||
/topic <text> - Set channel topic
|
||||
/users - List online users
|
||||
|
||||
@@ -228,10 +228,10 @@ public sealed class ApiClient : IDisposable
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var request = new CreateChannelRequest(name, topic, isPublic);
|
||||
var request = new CreateChannelRequest(name, topic, isPublic, password);
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync("/api/channels", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
|
||||
@@ -169,11 +169,21 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
|
||||
// ── Channel Operations ────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||
_joinedChannels.Add(channelName);
|
||||
return await _connection.JoinChannelAsync(channelName);
|
||||
try
|
||||
{
|
||||
var history = await _connection.JoinChannelAsync(channelName, password);
|
||||
_joinedChannels.Add(channelName);
|
||||
return history;
|
||||
}
|
||||
catch (ChannelPasswordRequiredException)
|
||||
{
|
||||
// Not actually joined — don't track, or reconnects would retry a doomed join
|
||||
_joinedChannels.Remove(channelName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LeaveChannelAsync(string channelName)
|
||||
|
||||
@@ -5,6 +5,20 @@ using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when joining a channel fails because a password is required or incorrect.
|
||||
/// The UI catches this to prompt the user and retry.
|
||||
/// </summary>
|
||||
public sealed class ChannelPasswordRequiredException : Exception
|
||||
{
|
||||
public string ChannelName { get; }
|
||||
|
||||
public ChannelPasswordRequiredException(string channelName, string message) : base(message)
|
||||
{
|
||||
ChannelName = channelName;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
@@ -134,11 +148,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName);
|
||||
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
|
||||
if (!result.Success)
|
||||
{
|
||||
if (result.PasswordRequired)
|
||||
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
|
||||
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||
}
|
||||
return DecryptMessages(result.History);
|
||||
}
|
||||
|
||||
|
||||
@@ -445,10 +445,44 @@ public static class ThemeManager
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly Theme TransparentLightTheme = new()
|
||||
{
|
||||
Name = "TransparentLight",
|
||||
Base = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Menu = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Dialog = new ThemeColors
|
||||
{
|
||||
Foreground = "Black",
|
||||
Background = "Gray",
|
||||
FocusForeground = "Blue",
|
||||
FocusBackground = "White"
|
||||
},
|
||||
Status = new ThemeColors
|
||||
{
|
||||
Foreground = "DarkGray",
|
||||
Background = "None",
|
||||
FocusForeground = "DarkGray",
|
||||
FocusBackground = "None"
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly List<Theme> BuiltInThemes =
|
||||
[
|
||||
DefaultTheme,
|
||||
TransparentTheme,
|
||||
TransparentLightTheme,
|
||||
ClassicTheme,
|
||||
LightTheme,
|
||||
HackerTheme,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Prompts for a channel password when joining a protected channel.
|
||||
/// Returns the entered password, or null if the user cancels.
|
||||
/// </summary>
|
||||
public sealed class ChannelPasswordDialog
|
||||
{
|
||||
public static string? Show(IApplication app, string channelName, string? message = null)
|
||||
{
|
||||
string? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = $"Join #{channelName}", Width = 50, Height = 10, CommandsToBubbleUp = [] };
|
||||
|
||||
var infoLabel = new Label
|
||||
{
|
||||
Text = message ?? $"#{channelName} is password protected.",
|
||||
X = 1,
|
||||
Y = 1
|
||||
};
|
||||
|
||||
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 3 };
|
||||
var passwordField = new TextField { X = 11, Y = 3, Width = Dim.Fill(2), Secret = true };
|
||||
|
||||
var joinButton = new Button
|
||||
{
|
||||
Text = "Join",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 9,
|
||||
Y = 5
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 2,
|
||||
Y = 5
|
||||
};
|
||||
|
||||
joinButton.Accepting += (s, e) =>
|
||||
{
|
||||
var password = passwordField.Text;
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
MessageBox.ErrorQuery(app, "Error", "Password is required.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
result = password;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(infoLabel, passwordLabel, passwordField, joinButton, cancelButton);
|
||||
|
||||
passwordField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Terminal.Gui.ViewBase;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic, string? Password);
|
||||
|
||||
public sealed class CreateChannelDialog
|
||||
{
|
||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
||||
{
|
||||
CreateChannelResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] };
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 16, CommandsToBubbleUp = [] };
|
||||
|
||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||
@@ -20,19 +20,22 @@ public sealed class CreateChannelDialog
|
||||
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
|
||||
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
|
||||
|
||||
var passwordLabel = new Label { Text = "Password:", X = 1, Y = 5 };
|
||||
var passwordField = new TextField { X = 11, Y = 5, Width = Dim.Fill(2), Secret = true };
|
||||
|
||||
var publicCheckbox = new CheckBox
|
||||
{
|
||||
Text = "Public (visible to all users)",
|
||||
X = 1,
|
||||
Y = 5,
|
||||
Y = 7,
|
||||
Value = CheckState.Checked
|
||||
};
|
||||
|
||||
var hintLabel = new Label
|
||||
{
|
||||
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
|
||||
Text = "Name: a-z, 0-9, -, _ (2-100 chars). Empty password = open channel.",
|
||||
X = 1,
|
||||
Y = 7,
|
||||
Y = 9,
|
||||
};
|
||||
|
||||
var createButton = new Button
|
||||
@@ -40,14 +43,14 @@ public sealed class CreateChannelDialog
|
||||
Text = "Create",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 10,
|
||||
Y = 9
|
||||
Y = 11
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 5,
|
||||
Y = 9
|
||||
Y = 11
|
||||
};
|
||||
|
||||
createButton.Accepting += (s, e) =>
|
||||
@@ -63,8 +66,12 @@ public sealed class CreateChannelDialog
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
topic = null;
|
||||
|
||||
var password = passwordField.Text;
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
password = null;
|
||||
|
||||
var isPublic = publicCheckbox.Value == CheckState.Checked;
|
||||
result = new CreateChannelResult(name, topic, isPublic);
|
||||
result = new CreateChannelResult(name, topic, isPublic, password);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -76,7 +83,8 @@ public sealed class CreateChannelDialog
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
|
||||
dialog.Add(nameLabel, nameField, topicLabel, topicField, passwordLabel, passwordField,
|
||||
publicCheckbox, hintLabel, createButton, cancelButton);
|
||||
|
||||
nameField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
@@ -16,6 +16,7 @@ public class ChannelListSource : IListDataSource
|
||||
{
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||
private readonly HashSet<string> _protectedChannels = [];
|
||||
private string _activeChannel = string.Empty;
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
@@ -28,13 +29,17 @@ public class ChannelListSource : IListDataSource
|
||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||
|
||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||
IReadOnlySet<string>? protectedChannels = null)
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelNames.AddRange(channels);
|
||||
_unreadCounts.Clear();
|
||||
foreach (var kv in unread)
|
||||
_unreadCounts[kv.Key] = kv.Value;
|
||||
_protectedChannels.Clear();
|
||||
if (protectedChannels is not null)
|
||||
_protectedChannels.UnionWith(protectedChannels);
|
||||
_activeChannel = activeChannel;
|
||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
@@ -57,7 +62,8 @@ public class ChannelListSource : IListDataSource
|
||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||
var prefix = isActive ? "> " : " ";
|
||||
var channelText = $"#{name}";
|
||||
// Trailing * marks password-protected (+k) channels
|
||||
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}";
|
||||
var badge = hasUnread ? $" ({unread})" : "";
|
||||
|
||||
// Resolve Transparent backgrounds to the view's actual background
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -50,6 +51,10 @@ public sealed partial class MainWindow : Runnable
|
||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||
private static readonly Key TabKey = Key.Tab;
|
||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||
private static readonly Key CtrlVKey = Key.V.WithCtrl;
|
||||
private static readonly Key CtrlXKey = Key.X.WithCtrl;
|
||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||
private static readonly Key CtrlYKey = Key.Y.WithCtrl;
|
||||
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
@@ -63,6 +68,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||
private readonly HashSet<string> _channelProtected = [];
|
||||
private readonly ChannelListSource _channelListSource;
|
||||
private readonly ChatMessageManager _messageManager;
|
||||
private string _connectionStatus = "Disconnected";
|
||||
@@ -253,6 +259,10 @@ public sealed partial class MainWindow : Runnable
|
||||
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);
|
||||
@@ -540,19 +550,81 @@ public sealed partial class MainWindow : Runnable
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||
{
|
||||
// Explicit paste support: terminals that don't intercept Ctrl+V themselves
|
||||
// otherwise leave users with only the right-click context menu.
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlXKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||
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 int _lastInputLength;
|
||||
|
||||
private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e)
|
||||
{
|
||||
var text = _inputField.Text;
|
||||
var previousLength = _lastInputLength;
|
||||
_lastInputLength = text?.Length ?? 0;
|
||||
|
||||
if (_suppressEmojiReplace)
|
||||
return;
|
||||
|
||||
var text = _inputField.Text;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
// A file dropped onto the terminal arrives as a pasted absolute path.
|
||||
// Detect multi-char bursts that resolve to existing files and route them
|
||||
// through /send instead of leaving a raw path in the input.
|
||||
if (text.Length - previousLength > 3 && TryGetDroppedFiles(text, out var droppedFiles))
|
||||
{
|
||||
var channel = _messageManager.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel))
|
||||
{
|
||||
_suppressEmojiReplace = true;
|
||||
try
|
||||
{
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
|
||||
foreach (var file in droppedFiles)
|
||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var replaced = EmojiHelper.ReplaceEmoji(text);
|
||||
if (replaced == text)
|
||||
return;
|
||||
@@ -562,9 +634,15 @@ public sealed partial class MainWindow : Runnable
|
||||
var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta);
|
||||
|
||||
_suppressEmojiReplace = true;
|
||||
_inputField.Text = replaced;
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||
_suppressEmojiReplace = false;
|
||||
try
|
||||
{
|
||||
_inputField.Text = replaced;
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressEmojiReplace = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -604,6 +682,80 @@ public sealed partial class MainWindow : Runnable
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interprets pasted text as one or more dropped files. Terminals deliver a file drop
|
||||
/// as the absolute path (quoted when it contains spaces; multiple files space-separated).
|
||||
/// Returns true only when the entire input resolves to existing files.
|
||||
/// </summary>
|
||||
private static bool TryGetDroppedFiles(string text, out List<string> files)
|
||||
{
|
||||
files = [];
|
||||
|
||||
var trimmed = text.Trim();
|
||||
if (trimmed.Length < 3 || trimmed.Length > 4096 || trimmed.Contains('\n'))
|
||||
return false;
|
||||
|
||||
// Single unquoted path, possibly with spaces (e.g. WSL or plain conhost drops)
|
||||
var unquoted = StripQuotes(trimmed);
|
||||
if (Path.IsPathFullyQualified(unquoted) && File.Exists(unquoted))
|
||||
{
|
||||
files.Add(unquoted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multiple files: space-separated tokens, each optionally quoted
|
||||
foreach (var token in TokenizeQuoted(trimmed))
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(token) || !File.Exists(token))
|
||||
{
|
||||
files.Clear();
|
||||
return false;
|
||||
}
|
||||
files.Add(token);
|
||||
}
|
||||
|
||||
return files.Count > 0;
|
||||
}
|
||||
|
||||
private static string StripQuotes(string s) =>
|
||||
s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))
|
||||
? s[1..^1]
|
||||
: s;
|
||||
|
||||
private static IEnumerable<string> TokenizeQuoted(string input)
|
||||
{
|
||||
var current = new System.Text.StringBuilder();
|
||||
var quote = '\0';
|
||||
|
||||
foreach (var c in input)
|
||||
{
|
||||
if (quote != '\0')
|
||||
{
|
||||
if (c == quote) quote = '\0';
|
||||
else current.Append(c);
|
||||
}
|
||||
else if (c is '"' or '\'')
|
||||
{
|
||||
quote = c;
|
||||
}
|
||||
else if (c == ' ')
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
yield return current.ToString();
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
yield return current.ToString();
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
{
|
||||
var newWidth = _messageList.Viewport.Width;
|
||||
@@ -675,11 +827,14 @@ public sealed partial class MainWindow : Runnable
|
||||
_channelNames.Clear();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
_channelProtected.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);
|
||||
}
|
||||
RefreshChannelList();
|
||||
}
|
||||
@@ -687,13 +842,23 @@ public sealed partial class MainWindow : Runnable
|
||||
/// <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)
|
||||
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
|
||||
{
|
||||
if (isPublic.HasValue)
|
||||
_channelPublic[channelName] = isPublic.Value;
|
||||
|
||||
if (isProtected.HasValue)
|
||||
{
|
||||
if (isProtected.Value) _channelProtected.Add(channelName);
|
||||
else _channelProtected.Remove(channelName);
|
||||
}
|
||||
|
||||
if (_channelNames.Contains(channelName))
|
||||
{
|
||||
if (isProtected.HasValue)
|
||||
RefreshChannelList();
|
||||
return;
|
||||
}
|
||||
|
||||
_channelNames.Add(channelName);
|
||||
RefreshChannelList();
|
||||
@@ -707,6 +872,7 @@ public sealed partial class MainWindow : Runnable
|
||||
_channelNames.Remove(channelName);
|
||||
_channelTopics.Remove(channelName);
|
||||
_channelPublic.Remove(channelName);
|
||||
_channelProtected.Remove(channelName);
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
@@ -792,6 +958,8 @@ public sealed partial class MainWindow : Runnable
|
||||
{
|
||||
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
||||
var typeSuffix = isPublic ? "public" : "private";
|
||||
if (_channelProtected.Contains(currentChannel))
|
||||
typeSuffix += " +k";
|
||||
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||
}
|
||||
|
||||
@@ -855,6 +1023,7 @@ public sealed partial class MainWindow : Runnable
|
||||
_messageManager.ClearAll();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
_channelProtected.Clear();
|
||||
_channelListSource.Update([], [], string.Empty);
|
||||
_channelList.Source = _channelListSource;
|
||||
_chatFrame.Title = "Chat";
|
||||
@@ -915,7 +1084,7 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
private void RefreshChannelList()
|
||||
{
|
||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected);
|
||||
_channelList.Source = _channelListSource;
|
||||
|
||||
// Restore selection to current channel
|
||||
|
||||
Reference in New Issue
Block a user