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
+92 -4
View File
@@ -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;