mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: Add invite codes and message replies functionality
- Introduced a new migration to add InviteCodes table and ReplyToMessageId column in Messages. - Updated ChatHub to support replying to messages. - Enhanced ChatService to handle message replies and validate reply targets. - Modified UserService to implement invite-only registration mode with invite code consumption. - Added configuration options for registration modes in appsettings. - Created unit tests for new features including invite code registration and message reply formatting.
This commit is contained in:
@@ -43,6 +43,9 @@ public sealed class AppOrchestrator : IDisposable
|
||||
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
|
||||
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Pending reply target — the next text message in that channel is sent as a reply to it.
|
||||
private (string Channel, Guid MessageId)? _pendingReply;
|
||||
|
||||
private ClientConfig _config;
|
||||
private readonly UserSession _session = new();
|
||||
|
||||
@@ -118,6 +121,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||
_mainWindow.OnReplyRequested += HandleReplyRequested;
|
||||
_mainWindow.OnReplyCancelRequested += HandleReplyCancelRequested;
|
||||
}
|
||||
|
||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||
@@ -150,17 +155,200 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
||||
_commandHandler.OnTestSound += HandleCmdTestSound;
|
||||
_commandHandler.OnQuit += HandleCmdQuit;
|
||||
_commandHandler.OnSendAction += HandleCmdSendAction;
|
||||
_commandHandler.OnSendBanner += HandleCmdSendBanner;
|
||||
_commandHandler.OnCreateInvite += HandleCmdCreateInvite;
|
||||
_commandHandler.OnListInvites += HandleCmdListInvites;
|
||||
_commandHandler.OnRevokeInvite += HandleCmdRevokeInvite;
|
||||
_commandHandler.OnExportData += HandleCmdExportData;
|
||||
_commandHandler.OnDeleteAccount += HandleCmdDeleteAccount;
|
||||
}
|
||||
|
||||
// ── Command Handlers ──────────────────────────────────────────────────
|
||||
|
||||
private async Task HandleCmdSetStatus(UserStatus status, string? message)
|
||||
private async Task HandleCmdSetStatus(UserStatus? status, string? message)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
await _conn.UpdateStatusAsync(status, message);
|
||||
_session.Status = status;
|
||||
_session.StatusMessage = message;
|
||||
// null status keeps the current one; null message keeps it, empty clears it —
|
||||
// so "/status away" no longer wipes your message and "/status msg brb" keeps Away
|
||||
var newStatus = status ?? _session.Status;
|
||||
var newMessage = message is null
|
||||
? _session.StatusMessage
|
||||
: (message.Length == 0 ? null : message);
|
||||
|
||||
await _conn.UpdateStatusAsync(newStatus, newMessage);
|
||||
_session.Status = newStatus;
|
||||
_session.StatusMessage = newMessage;
|
||||
}
|
||||
|
||||
private Task HandleCmdSendAction(string text)
|
||||
{
|
||||
if (!_conn.IsConnected) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
// CTCP ACTION content flows through the normal send path — room encryption included
|
||||
RunAsync(async () =>
|
||||
{
|
||||
if (!await EnsureRoomUnlockedForSendAsync(channel))
|
||||
return;
|
||||
await _conn.SendMessageAsync(channel, MessageConventions.FormatAction(text));
|
||||
}, "Send failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdSendBanner(string text)
|
||||
{
|
||||
if (!_conn.IsConnected) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
var banner = AsciiBannerService.Render(text);
|
||||
if (banner is null)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError(
|
||||
$"Nothing to render — /banner supports letters, digits, and basic punctuation (max {AsciiBannerService.MaxInputLength} chars)."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
if (!await EnsureRoomUnlockedForSendAsync(channel))
|
||||
return;
|
||||
await _conn.SendMessageAsync(channel, banner);
|
||||
}, "Send failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdCreateInvite(int? maxUses, int? expiresHours)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var invite = await _conn.Api!.CreateInviteAsync(maxUses, expiresHours);
|
||||
if (invite is null) return;
|
||||
var expiry = invite.ExpiresAt is { } exp ? $", expires {exp.ToLocalTime():yyyy-MM-dd HH:mm}" : "";
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel,
|
||||
$"Invite code: {invite.Code} (uses: {invite.MaxUses}{expiry})\n" +
|
||||
$"Share it out-of-band. Revoke with: /invite revoke {invite.Code}"));
|
||||
}, "Failed to create invite");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdListInvites()
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var invites = await _conn.Api!.GetInvitesAsync();
|
||||
var text = invites.Count == 0
|
||||
? "No invite codes. Create one with /invite [uses] [hours]."
|
||||
: string.Join('\n', invites.Select(i =>
|
||||
{
|
||||
var state = i.UseCount >= i.MaxUses ? "used up"
|
||||
: i.ExpiresAt is { } exp && exp <= DateTimeOffset.UtcNow ? "expired"
|
||||
: i.ExpiresAt is { } e2 ? $"expires {e2.ToLocalTime():yyyy-MM-dd HH:mm}"
|
||||
: "active";
|
||||
return $"{i.Code} {i.UseCount}/{i.MaxUses} used ({state}, by {i.CreatedByUsername})";
|
||||
}));
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, text));
|
||||
}, "Failed to list invites");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdRevokeInvite(string code)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
await _conn.Api!.RevokeInviteAsync(code);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Invite {code.ToUpperInvariant()} revoked."));
|
||||
}, "Failed to revoke invite");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdExportData()
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var json = await _conn.Api!.ExportMyDataAsync();
|
||||
var fileName = $"echohub-export-{_session.Username}-{DateTime.Now:yyyyMMdd-HHmmss}.json";
|
||||
var destination = DedupPath(GetDownloadDir(), fileName);
|
||||
await File.WriteAllTextAsync(destination, json);
|
||||
InvokeUI(() =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(channel))
|
||||
_messageManager.AddSystemMessage(channel,
|
||||
$"Data export saved to: {destination}\n(Encrypted-room content in it is ciphertext — the server never had the plaintext.)");
|
||||
});
|
||||
}, "Export failed");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleCmdDeleteAccount()
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return Task.CompletedTask;
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
var confirm = MessageBox.ErrorQuery(_app, "Delete Account",
|
||||
$"This permanently deletes '{_session.Username}' on this server:\n" +
|
||||
"profile, sessions, and every file you uploaded.\n" +
|
||||
"Your messages remain, attributed to 'deleted-user'.\n\n" +
|
||||
"This cannot be undone.",
|
||||
"Cancel", "Delete my account");
|
||||
if (confirm != 1) return;
|
||||
|
||||
var password = PromptPassword("Enter your password to confirm deletion:");
|
||||
if (password is null) return;
|
||||
|
||||
PersistLastReads();
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var baseUrl = _conn.Api!.BaseUrl;
|
||||
await _conn.Api!.DeleteMyAccountAsync(password);
|
||||
ClearSavedToken(baseUrl);
|
||||
await _conn.CleanupAsync();
|
||||
InvokeUI(() =>
|
||||
{
|
||||
_mainWindow.ClearAll();
|
||||
_mainWindow.UpdateStatusBar("Disconnected");
|
||||
MessageBox.Query(_app, "Account Deleted",
|
||||
"Your account and uploaded files have been deleted from this server.", "OK");
|
||||
});
|
||||
}, "Account deletion failed", "DeleteAccount");
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Modal password prompt; returns null when cancelled or empty.</summary>
|
||||
private string? PromptPassword(string prompt)
|
||||
{
|
||||
string? result = null;
|
||||
var dialog = new Dialog { Title = "Confirm Password", Width = 56, Height = 8 };
|
||||
var label = new Label { Text = prompt, X = 1, Y = 1 };
|
||||
var field = new TextField { X = 1, Y = 2, Width = Terminal.Gui.ViewBase.Dim.Fill(2), Secret = true };
|
||||
var ok = new Button { Text = "Confirm", IsDefault = true, X = Terminal.Gui.ViewBase.Pos.Center() - 12, Y = 4 };
|
||||
var cancel = new Button { Text = "Cancel", X = Terminal.Gui.ViewBase.Pos.Center() + 2, Y = 4 };
|
||||
ok.Accepting += (_, e) => { result = field.Text; e.Handled = true; _app.RequestStop(); };
|
||||
cancel.Accepting += (_, e) => { result = null; e.Handled = true; _app.RequestStop(); };
|
||||
dialog.Add(label, field, ok, cancel);
|
||||
field.SetFocus();
|
||||
_app.Run(dialog);
|
||||
return string.IsNullOrEmpty(result) ? null : result;
|
||||
}
|
||||
|
||||
private async Task HandleCmdSetNick(string displayName)
|
||||
@@ -1193,6 +1381,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
{
|
||||
Log.Information("Disconnecting from server");
|
||||
lock (_channelUsersLock) _channelUsers.Clear();
|
||||
_pendingReply = null;
|
||||
InvokeUI(() => _mainWindow.SetReplyingTo(null));
|
||||
PersistLastReads();
|
||||
|
||||
RunAsync(async () =>
|
||||
@@ -1262,14 +1452,40 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// A pending reply only applies to a plain text message in its own channel
|
||||
Guid? replyTo = _pendingReply is { } pending
|
||||
&& pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase)
|
||||
? pending.MessageId : null;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
if (!await EnsureRoomUnlockedForSendAsync(channelName))
|
||||
return;
|
||||
await _conn.SendMessageAsync(channelName, content);
|
||||
await _conn.SendMessageAsync(channelName, content, replyTo);
|
||||
if (replyTo is not null)
|
||||
InvokeUI(ClearPendingReply);
|
||||
}, "Send failed");
|
||||
}
|
||||
|
||||
private void HandleReplyRequested(Guid messageId, string sender, string snippet)
|
||||
{
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
_pendingReply = (channel, messageId);
|
||||
if (snippet.Length > 40)
|
||||
snippet = snippet[..40] + "…";
|
||||
_mainWindow.SetReplyingTo($"{sender}: {snippet}");
|
||||
}
|
||||
|
||||
private void HandleReplyCancelRequested() => ClearPendingReply();
|
||||
|
||||
private void ClearPendingReply()
|
||||
{
|
||||
_pendingReply = null;
|
||||
_mainWindow.SetReplyingTo(null);
|
||||
}
|
||||
|
||||
private void HandleDeleteMessageRequested(Guid messageId)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
@@ -1284,6 +1500,10 @@ public sealed class AppOrchestrator : IDisposable
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
// A reply pending in the previous channel doesn't carry over
|
||||
if (_pendingReply is { } pending && !pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase))
|
||||
InvokeUI(ClearPendingReply);
|
||||
|
||||
// Checkpoint read positions — the previous channel was just marked read
|
||||
PersistLastReads();
|
||||
|
||||
|
||||
@@ -6,7 +6,19 @@ public record CommandResult(bool Handled, string? Message = null, bool IsError =
|
||||
|
||||
public class CommandHandler
|
||||
{
|
||||
public event Func<UserStatus, string?, Task>? OnSetStatus;
|
||||
/// <summary>
|
||||
/// Status update. A null status means "keep the current status"; a null message means
|
||||
/// "keep the current message" and an empty message means "clear it". The orchestrator
|
||||
/// resolves both against the session state.
|
||||
/// </summary>
|
||||
public event Func<UserStatus?, string?, Task>? OnSetStatus;
|
||||
public event Func<string, Task>? OnSendAction;
|
||||
public event Func<string, Task>? OnSendBanner;
|
||||
public event Func<int?, int?, Task>? OnCreateInvite;
|
||||
public event Func<Task>? OnListInvites;
|
||||
public event Func<string, Task>? OnRevokeInvite;
|
||||
public event Func<Task>? OnExportData;
|
||||
public event Func<Task>? OnDeleteAccount;
|
||||
public event Func<string, Task>? OnSetNick;
|
||||
public event Func<string, Task>? OnSetColor;
|
||||
public event Func<string, Task>? OnSetTheme;
|
||||
@@ -48,6 +60,11 @@ public class CommandHandler
|
||||
return command switch
|
||||
{
|
||||
"status" => await HandleStatus(args),
|
||||
"me" => await HandleMe(args),
|
||||
"banner" => await HandleBanner(args),
|
||||
"invite" => await HandleInvite(args),
|
||||
"export" => await HandleExport(),
|
||||
"deleteaccount" => await HandleDeleteAccount(),
|
||||
"nick" => await HandleNick(args),
|
||||
"color" => await HandleColor(args),
|
||||
"theme" => await HandleTheme(args),
|
||||
@@ -78,12 +95,28 @@ public class CommandHandler
|
||||
};
|
||||
}
|
||||
|
||||
private const string StatusUsage =
|
||||
"Usage: /status <online|away|dnd|invisible> or /status msg <text> (empty text clears it)";
|
||||
|
||||
private async Task<CommandResult> HandleStatus(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
|
||||
return new CommandResult(true, StatusUsage, IsError: true);
|
||||
|
||||
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var statusArg = parts[0].ToLowerInvariant();
|
||||
|
||||
// /status msg <text> — set/clear the message, keep the current status
|
||||
if (statusArg is "msg" or "message")
|
||||
{
|
||||
var message = parts.Length > 1 ? parts[1] : string.Empty;
|
||||
if (OnSetStatus is not null)
|
||||
await OnSetStatus(null, message);
|
||||
return new CommandResult(true, message.Length > 0
|
||||
? $"Status message set: {message}"
|
||||
: "Status message cleared.");
|
||||
}
|
||||
|
||||
var statusArg = args.ToLowerInvariant().Trim();
|
||||
UserStatus? status = statusArg switch
|
||||
{
|
||||
"online" => UserStatus.Online,
|
||||
@@ -93,17 +126,92 @@ public class CommandHandler
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (status.HasValue)
|
||||
// Strict: anything else is an error — no silent "it became your status message"
|
||||
if (!status.HasValue)
|
||||
return new CommandResult(true, $"Unknown status '{parts[0]}'. {StatusUsage}", IsError: true);
|
||||
|
||||
if (parts.Length > 1)
|
||||
return new CommandResult(true, StatusUsage, IsError: true);
|
||||
|
||||
if (OnSetStatus is not null)
|
||||
await OnSetStatus(status.Value, null);
|
||||
return new CommandResult(true, $"Status set to {status.Value}");
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleMe(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /me <action> (e.g. /me waves)", IsError: true);
|
||||
|
||||
if (OnSendAction is not null)
|
||||
await OnSendAction(args.Trim());
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleBanner(string args)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(args))
|
||||
return new CommandResult(true, "Usage: /banner <text> (letters, digits, basic punctuation)", IsError: true);
|
||||
|
||||
if (OnSendBanner is not null)
|
||||
await OnSendBanner(args.Trim());
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleInvite(string args)
|
||||
{
|
||||
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
// /invite list
|
||||
if (parts.Length > 0 && parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (OnSetStatus is not null)
|
||||
await OnSetStatus(status.Value, null);
|
||||
return new CommandResult(true, $"Status set to {status.Value}");
|
||||
if (OnListInvites is not null)
|
||||
await OnListInvites();
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
// Treat as custom status message (keep current status)
|
||||
if (OnSetStatus is not null)
|
||||
await OnSetStatus(UserStatus.Online, args);
|
||||
return new CommandResult(true, $"Status message set: {args}");
|
||||
// /invite revoke <code>
|
||||
if (parts.Length > 0 && parts[0].Equals("revoke", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
return new CommandResult(true, "Usage: /invite revoke <code>", IsError: true);
|
||||
if (OnRevokeInvite is not null)
|
||||
await OnRevokeInvite(parts[1]);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
// /invite [maxUses] [expiresHours] — defaults to a single-use, never-expiring code
|
||||
int? maxUses = null, expiresHours = null;
|
||||
if (parts.Length > 0)
|
||||
{
|
||||
if (!int.TryParse(parts[0], out var uses) || uses < 1)
|
||||
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours] | /invite list | /invite revoke <code>", IsError: true);
|
||||
maxUses = uses;
|
||||
}
|
||||
if (parts.Length > 1)
|
||||
{
|
||||
if (!int.TryParse(parts[1], out var hours) || hours < 1)
|
||||
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours]", IsError: true);
|
||||
expiresHours = hours;
|
||||
}
|
||||
|
||||
if (OnCreateInvite is not null)
|
||||
await OnCreateInvite(maxUses, expiresHours);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleExport()
|
||||
{
|
||||
if (OnExportData is not null)
|
||||
await OnExportData();
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleDeleteAccount()
|
||||
{
|
||||
if (OnDeleteAccount is not null)
|
||||
await OnDeleteAccount();
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleNick(string args)
|
||||
@@ -392,7 +500,9 @@ public class CommandHandler
|
||||
return new CommandResult(true, """
|
||||
Available commands:
|
||||
/status <online|away|dnd|invisible> - Set your status
|
||||
/status <message> - Set status message
|
||||
/status msg <text> - Set status message (empty = clear)
|
||||
/me <action> - Action message (* nick waves)
|
||||
/banner <text> - Send text as an ASCII banner
|
||||
/nick <name> - Set display name
|
||||
/color <#hex> - Set nickname color
|
||||
/theme <name> - Switch theme
|
||||
@@ -413,6 +523,9 @@ public class CommandHandler
|
||||
/topic <text> - Set channel topic
|
||||
/users - List online users
|
||||
/meta - Show room info (size, messages, users, created, id)
|
||||
/export - Download everything the server stores about you
|
||||
/deleteaccount - Permanently delete your account
|
||||
(Tip: right-click a message and pick Reply to quote it; Esc cancels a pending reply.)
|
||||
Moderation:
|
||||
/kick <user> [reason] - Kick a user (Mod+)
|
||||
/ban <user> [reason] - Ban a user (Admin+)
|
||||
@@ -420,6 +533,8 @@ public class CommandHandler
|
||||
/mute <user> [minutes] - Mute a user (Mod+)
|
||||
/unmute <user> - Unmute a user (Mod+)
|
||||
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||
/invite [uses] [hours] - Create a registration invite code (Admin+)
|
||||
/invite list | revoke <code> - Manage invite codes (Admin+)
|
||||
/nuke - Clear channel history (Mod+)
|
||||
/test-sound - Play notification sound
|
||||
/quit - Exit the app
|
||||
|
||||
@@ -29,9 +29,9 @@ public sealed class ApiClient : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
|
||||
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null, string? inviteCode = null)
|
||||
{
|
||||
var request = new RegisterRequest(username, password, displayName);
|
||||
var request = new RegisterRequest(username, password, displayName, inviteCode);
|
||||
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||
await EnsureSuccessAsync(response);
|
||||
|
||||
@@ -312,6 +312,54 @@ public sealed class ApiClient : IDisposable
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
|
||||
// ── Invites / Account ─────────────────────────────────────────────────
|
||||
|
||||
public async Task<InviteDto?> CreateInviteAsync(int? maxUses = null, int? expiresInHours = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync("/api/invites", new CreateInviteRequest(maxUses, expiresInHours)));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<InviteDto>();
|
||||
}
|
||||
|
||||
public async Task<List<InviteDto>> GetInvitesAsync()
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedGetAsync("/api/invites");
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<List<InviteDto>>() ?? [];
|
||||
}
|
||||
|
||||
public async Task RevokeInviteAsync(string code)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.DeleteAsync($"/api/invites/{Uri.EscapeDataString(code)}"));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
|
||||
/// <summary>Downloads the caller's full data export as raw JSON text.</summary>
|
||||
public async Task<string> ExportMyDataAsync()
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedGetAsync("/api/users/me/export");
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
/// <summary>Deletes the caller's account. The password re-confirms intent.</summary>
|
||||
public async Task DeleteMyAccountAsync(string password)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/api/users/me")
|
||||
{
|
||||
Content = JsonContent.Create(new DeleteAccountRequest(password)),
|
||||
}));
|
||||
await EnsureSuccessAsync(response);
|
||||
}
|
||||
|
||||
// ── Moderation ────────────────────────────────────────────────────────
|
||||
|
||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||
|
||||
@@ -78,7 +78,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
}
|
||||
else if (info.IsRegister)
|
||||
{
|
||||
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
||||
loginResponse = await _apiClient.RegisterAsync(
|
||||
info.Username, info.Password, info.DisplayName, info.InviteCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -244,8 +245,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
|
||||
// ── Delegate Operations ───────────────────────────────────────────────
|
||||
|
||||
public Task SendMessageAsync(string channel, string content) =>
|
||||
_connection?.SendMessageAsync(channel, content)
|
||||
public Task SendMessageAsync(string channel, string content, Guid? replyToMessageId = null) =>
|
||||
_connection?.SendMessageAsync(channel, content, replyToMessageId)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||
|
||||
@@ -198,7 +198,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
await _connection.InvokeAsync("LeaveChannel", channelName);
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string channelName, string content)
|
||||
public async Task SendMessageAsync(string channelName, string content, Guid? replyToMessageId = null)
|
||||
{
|
||||
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||
@@ -207,7 +207,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
throw new RoomLockedException(channelName); // never fall through to plaintext
|
||||
|
||||
var encrypted = _encryption.Encrypt(content);
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted, replyToMessageId);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
@@ -250,7 +250,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return message with { Content = content, Attachments = attachments };
|
||||
// Reply snippets are encrypted exactly like message content
|
||||
var replyTo = message.ReplyTo is { } reply
|
||||
? reply with { Content = DecryptField(reply.Content, roomKey) ?? LockedMessagePlaceholder }
|
||||
: null;
|
||||
|
||||
return message with { Content = content, Attachments = attachments, ReplyTo = replyTo };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -31,6 +31,12 @@ public partial class ChatLine
|
||||
public AttachmentKind? AttachmentKind { get; set; }
|
||||
public string? SenderUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set on a reply's quote line: activating the line jumps to this message
|
||||
/// if it is in the loaded history.
|
||||
/// </summary>
|
||||
public Guid? JumpToMessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
|
||||
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
|
||||
@@ -176,6 +182,7 @@ public partial class ChatLine
|
||||
wrapped.MessageId = MessageId;
|
||||
wrapped.SenderUsername = SenderUsername;
|
||||
wrapped.IsMention = IsMention;
|
||||
wrapped.JumpToMessageId = JumpToMessageId;
|
||||
}
|
||||
|
||||
// Span columns only line up with the first wrapped line; later lines fall
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Client.UI.Helpers;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using Terminal.Gui.Drawing;
|
||||
@@ -312,7 +313,9 @@ public sealed class ChatMessageManager
|
||||
if (!string.IsNullOrEmpty(_currentUser))
|
||||
{
|
||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||
if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)))
|
||||
if (messages.Skip(firstUnread).Any(m =>
|
||||
Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)
|
||||
|| (m.ReplyTo is { } reply && reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase))))
|
||||
_mentionChannels.Add(channelName);
|
||||
}
|
||||
}
|
||||
@@ -391,8 +394,36 @@ public sealed class ChatMessageManager
|
||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||
var attachments = message.Attachments ?? [];
|
||||
|
||||
// Reply → dim quote line above the message; activating it jumps to the original
|
||||
if (message.ReplyTo is { } replyTo)
|
||||
lines.Add(ReplyQuoteLine(replyTo));
|
||||
|
||||
// /me action → "* nick waves" (CTCP ACTION content)
|
||||
string? actionText = null;
|
||||
var isAction = hasContent && MessageConventions.TryParseAction(message.Content, out actionText);
|
||||
if (isAction)
|
||||
{
|
||||
var actionLines = EmojiHelper.ReplaceEmoji(actionText!).Split('\n');
|
||||
var header = ActionHeaderSegments(time);
|
||||
header.Add(new(senderName, senderColor));
|
||||
header.Add(new(" ", null));
|
||||
header.AddRange(ChatColors.SplitMentions(actionLines[0].TrimEnd('\r')));
|
||||
lines.Add(new ChatLine(header));
|
||||
|
||||
for (int i = 1; i < actionLines.Length; i++)
|
||||
{
|
||||
var segments = RailPrefix();
|
||||
segments.AddRange(ChatColors.SplitMentions(actionLines[i].TrimEnd('\r')));
|
||||
lines.Add(new ChatLine(segments));
|
||||
}
|
||||
}
|
||||
|
||||
// Header line: caption text, or a summary when the message is attachments-only
|
||||
if (hasContent)
|
||||
if (isAction)
|
||||
{
|
||||
// already rendered above
|
||||
}
|
||||
else if (hasContent)
|
||||
{
|
||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||
var contentLines = displayContent.Split('\n');
|
||||
@@ -474,10 +505,14 @@ public sealed class ChatMessageManager
|
||||
line.SenderUsername = message.SenderUsername;
|
||||
}
|
||||
|
||||
if (hasContent && !string.IsNullOrEmpty(_currentUser))
|
||||
if (!string.IsNullOrEmpty(_currentUser))
|
||||
{
|
||||
// Being replied to counts as a mention, same as an explicit @nick
|
||||
var isReplyToMe = message.ReplyTo is { } reply
|
||||
&& reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase);
|
||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||
if (isReplyToMe
|
||||
|| ((hasContent || isAction) && Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)))
|
||||
{
|
||||
foreach (var line in lines)
|
||||
line.IsMention = true;
|
||||
@@ -558,6 +593,54 @@ public sealed class ChatMessageManager
|
||||
new(" │ ", ChatColors.RailAttr),
|
||||
];
|
||||
|
||||
/// <summary>Header variant for /me actions: "*" in the nick column, "* nick text" content.</summary>
|
||||
private static List<ChatSegment> ActionHeaderSegments(string time) =>
|
||||
[
|
||||
new($"{time} ", ChatColors.TimestampAttr),
|
||||
new(PadNick("*"), ChatColors.TimestampAttr),
|
||||
new(" │ ", ChatColors.RailAttr),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The dim "┌ nick: snippet" line above a reply. Carries the original message id so
|
||||
/// activating it jumps there. Room-encrypted snippets arrive already decrypted (or as
|
||||
/// the locked placeholder) — this only truncates for display.
|
||||
/// </summary>
|
||||
private static ChatLine ReplyQuoteLine(ReplyRefDto replyTo)
|
||||
{
|
||||
const int maxSnippetCols = 60;
|
||||
|
||||
var snippet = replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
|
||||
if (MessageConventions.TryParseAction(snippet, out var actionText))
|
||||
snippet = $"* {replyTo.SenderUsername} {actionText}";
|
||||
|
||||
snippet = EmojiHelper.ReplaceEmoji(snippet);
|
||||
if (snippet.GetColumns() > maxSnippetCols)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int used = 0;
|
||||
foreach (var g in GraphemeHelper.GetGraphemes(snippet))
|
||||
{
|
||||
var gCols = Math.Max(g.GetColumns(), 1);
|
||||
if (used + gCols > maxSnippetCols - 1) break;
|
||||
sb.Append(g);
|
||||
used += gCols;
|
||||
}
|
||||
snippet = sb.Append('…').ToString();
|
||||
}
|
||||
|
||||
var segments = RailPrefix();
|
||||
segments.Add(new("┌ ", ChatColors.RailAttr));
|
||||
segments.Add(new($"{replyTo.SenderUsername}: ", NickColorHelper.GetAttribute(replyTo.SenderUsername)));
|
||||
segments.Add(new(snippet, ChatColors.SystemAttr));
|
||||
|
||||
return new ChatLine(segments)
|
||||
{
|
||||
JumpToMessageId = replyTo.MessageId,
|
||||
ContinuationPrefixSegments = RailPrefix(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indent segments aligning continuation/attachment/embed lines under the message
|
||||
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace EchoHub.Client.UI.Dialogs;
|
||||
/// </summary>
|
||||
public record ConnectDialogResult(
|
||||
string ServerUrl, string Username, string Password,
|
||||
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
|
||||
bool IsRegister, bool RememberMe, string? SavedRefreshToken,
|
||||
string? DisplayName = null, string? InviteCode = null);
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||
@@ -29,7 +30,7 @@ public sealed class ConnectDialog
|
||||
savedServers ??= [];
|
||||
|
||||
var hasSavedServers = savedServers.Count > 0;
|
||||
var dialogHeight = hasSavedServers ? 22 : 18;
|
||||
var dialogHeight = hasSavedServers ? 24 : 20;
|
||||
|
||||
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||
|
||||
@@ -153,26 +154,41 @@ public sealed class ConnectDialog
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
// Only needed on servers with invite-gated registration; harmless elsewhere
|
||||
var inviteLabel = new Label
|
||||
{
|
||||
Text = "Invite Code:",
|
||||
X = 1,
|
||||
Y = yOffset + 11
|
||||
};
|
||||
var inviteField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 15,
|
||||
Y = yOffset + 11,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var loginButton = new Button
|
||||
{
|
||||
Text = "Login",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 20,
|
||||
Y = yOffset + 11
|
||||
Y = yOffset + 13
|
||||
};
|
||||
|
||||
var registerButton = new Button
|
||||
{
|
||||
Text = "Register",
|
||||
X = Pos.Center() - 5,
|
||||
Y = yOffset + 11
|
||||
Y = yOffset + 13
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 10,
|
||||
Y = yOffset + 11
|
||||
Y = yOffset + 13
|
||||
};
|
||||
|
||||
// Wire saved server selection to auto-fill fields
|
||||
@@ -262,7 +278,11 @@ public sealed class ConnectDialog
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
||||
var displayName = displayField.Text?.Trim();
|
||||
var inviteCode = inviteField.Text?.Trim();
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null,
|
||||
DisplayName: string.IsNullOrEmpty(displayName) ? null : displayName,
|
||||
InviteCode: string.IsNullOrEmpty(inviteCode) ? null : inviteCode);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -276,6 +296,7 @@ public sealed class ConnectDialog
|
||||
|
||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
||||
inviteLabel, inviteField,
|
||||
loginButton, registerButton, cancelButton);
|
||||
|
||||
if (hasSavedServers && savedServerList is not null)
|
||||
|
||||
@@ -64,10 +64,11 @@ public sealed partial class MainWindow : Runnable
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
[
|
||||
"/status", "/nick", "/color", "/theme", "/send",
|
||||
"/status", "/nick", "/color", "/theme", "/send", "/me", "/banner",
|
||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||
"/mute", "/unmute", "/role", "/invite", "/export", "/deleteaccount",
|
||||
"/nuke", "/test-sound", "/quit", "/help"
|
||||
];
|
||||
|
||||
private readonly List<string> _channelNames = [];
|
||||
@@ -187,6 +188,17 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </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>
|
||||
@@ -357,6 +369,9 @@ public sealed partial class MainWindow : Runnable
|
||||
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.
|
||||
@@ -366,15 +381,38 @@ public sealed partial class MainWindow : Runnable
|
||||
_hasStagedAttachments = fileNames.Count > 0;
|
||||
if (fileNames.Count == 0)
|
||||
{
|
||||
_inputFrame.Title = DefaultInputTitle;
|
||||
_stagedTitleFragment = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = string.Join(", ", fileNames);
|
||||
if (names.Length > 45)
|
||||
names = names[..42] + "...";
|
||||
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
|
||||
_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()
|
||||
{
|
||||
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
|
||||
{
|
||||
(null, null) => DefaultInputTitle,
|
||||
({ } reply, null) => reply,
|
||||
(null, { } staged) => staged,
|
||||
({ } reply, { } staged) => $"{reply} │ {staged}",
|
||||
};
|
||||
_inputFrame.SetNeedsDraw();
|
||||
}
|
||||
|
||||
@@ -519,6 +557,14 @@ public sealed partial class MainWindow : Runnable
|
||||
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)
|
||||
{
|
||||
@@ -681,6 +727,20 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -722,6 +782,30 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
}
|
||||
|
||||
/// <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");
|
||||
@@ -757,6 +841,10 @@ public sealed partial class MainWindow : Runnable
|
||||
TryAutocompleteCommand();
|
||||
break;
|
||||
|
||||
case KeyCode.Esc when HasPendingReplyIndicator:
|
||||
OnReplyCancelRequested?.Invoke();
|
||||
break;
|
||||
|
||||
case NewlineKey:
|
||||
_inputField.InsertText("\n");
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user