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
+7
View File
@@ -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.
+27 -6
View File
@@ -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)
+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;