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:
HueByte
2026-07-17 19:47:57 +02:00
parent bb987dda82
commit 3281064720
44 changed files with 2484 additions and 74 deletions
+225 -5
View File
@@ -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();