Add chat composer autocomplete for mentions and channels

This commit is contained in:
Stone_Red
2026-07-16 18:47:20 +02:00
parent b98a00b527
commit 24e1672420
10 changed files with 385 additions and 13 deletions
@@ -0,0 +1,103 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
namespace Decho.ViewModels;
public sealed class AutocompleteController : ViewModelBase
{
private readonly List<AutocompleteProvider> _providers;
private static readonly Regex TriggerPattern = new(@"([@#])(\w*)$", RegexOptions.Compiled);
public ObservableCollection<string> FilteredItems { get; } = [];
public bool ShowPopup
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public int SelectedIndex
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public string FilterText
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public AutocompleteProvider? ActiveProvider { get; private set; }
public int TriggerCharIndex { get; private set; }
public AutocompleteController(IEnumerable<AutocompleteProvider> providers)
{
_providers = providers.ToList();
}
public void Update(string text)
{
if (string.IsNullOrEmpty(text) || _providers.Count == 0)
{
Reset();
return;
}
Match match = TriggerPattern.Match(text);
if (!match.Success)
{
Reset();
return;
}
char trigger = match.Groups[1].Value[0];
AutocompleteProvider? provider = _providers.FirstOrDefault(p => p.Trigger == trigger);
if (provider is null)
{
Reset();
return;
}
string filter = match.Groups[2].Value;
ActiveProvider = provider;
FilterText = filter;
TriggerCharIndex = match.Index;
List<string> items = provider.ItemsSource().ToList();
List<string> filtered = items
.Where(item => provider.Filter(item, filter))
.Take(provider.MaxResults)
.ToList();
SelectedIndex = -1;
FilteredItems.Clear();
foreach (string item in filtered)
{
FilteredItems.Add(item);
}
ShowPopup = FilteredItems.Count > 0;
SelectedIndex = 0;
}
public string? GetInsertion(string item)
{
if (ActiveProvider is null)
{
return null;
}
return ActiveProvider.FormatInsertion(item);
}
public void Reset()
{
ShowPopup = false;
ActiveProvider = null;
FilterText = string.Empty;
FilteredItems.Clear();
}
}
@@ -0,0 +1,23 @@
namespace Decho.ViewModels;
public sealed class AutocompleteProvider
{
public char Trigger { get; }
public Func<IEnumerable<string>> ItemsSource { get; }
public Func<string, string, bool> Filter { get; }
public Func<string, string> FormatInsertion { get; }
public int MaxResults { get; }
public AutocompleteProvider(
char trigger,
Func<IEnumerable<string>> itemsSource,
string insertPrefix,
int maxResults = 10)
{
Trigger = trigger;
ItemsSource = itemsSource;
Filter = (item, filter) => item.StartsWith(filter, StringComparison.OrdinalIgnoreCase);
FormatInsertion = item => $"{insertPrefix}{item} ";
MaxResults = maxResults;
}
}
+3
View File
@@ -110,6 +110,7 @@ public sealed class ChatViewModel : ViewModelBase
}
OnlineUserCount = $"{users.Count}";
ShowOnlineUsers = true;
Composer.UpdateAvailableUsers(OnlineUsers);
}
public void AddOnlineUser(UserPresenceDto user)
@@ -120,6 +121,7 @@ public sealed class ChatViewModel : ViewModelBase
OnlineUserCount = $"{OnlineUsers.Count}";
}
ShowOnlineUsers = OnlineUsers.Count > 0;
Composer.UpdateAvailableUsers(OnlineUsers);
}
public void RemoveOnlineUser(string username)
@@ -131,6 +133,7 @@ public sealed class ChatViewModel : ViewModelBase
OnlineUserCount = $"{OnlineUsers.Count}";
}
ShowOnlineUsers = OnlineUsers.Count > 0;
Composer.UpdateAvailableUsers(OnlineUsers);
}
public void ClearMessages()
@@ -827,6 +827,12 @@ public sealed class MainWindowViewModel : ViewModelBase
bool isServerConnected = Sidebar.GetServer(serverUrl)?.IsConnected ?? false;
Chat.SetChannel(channel, serverUrl, isServerConnected);
ServerViewModel? currentServer = Sidebar.GetServer(serverUrl);
if (currentServer is not null)
{
Chat.Composer.UpdateAvailableChannels(currentServer.Channels.Select(c => c.Name));
}
if (!string.IsNullOrEmpty(serverUrl))
{
Chat.Composer.SetCommandHandler(new CommandHandler());
@@ -1,5 +1,6 @@
using EchoHub.Client.Commands;
using System.Collections.ObjectModel;
using System.Reactive;
namespace Decho.ViewModels;
@@ -14,6 +15,9 @@ public sealed class MessageComposerViewModel : ViewModelBase
private CommandHandler? _commandHandler;
private readonly ObservableCollection<UserViewModel> _onlineUsers = [];
private readonly ObservableCollection<string> _channelNames = [];
public string Draft
{
get;
@@ -32,10 +36,44 @@ public sealed class MessageComposerViewModel : ViewModelBase
public bool HasCommandHandler => _commandHandler is not null;
public AutocompleteController Autocomplete { get; }
public MessageComposerViewModel()
{
AutocompleteProvider mentionProvider = new(
trigger: '@',
itemsSource: () => _onlineUsers.Select(u => u.Username),
insertPrefix: "@");
AutocompleteProvider channelProvider = new(
trigger: '#',
itemsSource: () => _channelNames,
insertPrefix: "#");
Autocomplete = new AutocompleteController([mentionProvider, channelProvider]);
IObservable<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
SendCommand = ReactiveCommand.Create(Send, canSend);
_ = this.WhenAnyValue(x => x.Draft).Subscribe(OnDraftChanged);
}
public void UpdateAvailableUsers(IEnumerable<UserViewModel> users)
{
_onlineUsers.Clear();
foreach (UserViewModel user in users)
{
_onlineUsers.Add(user);
}
}
public void UpdateAvailableChannels(IEnumerable<string> channelNames)
{
_channelNames.Clear();
foreach (string name in channelNames)
{
_channelNames.Add(name);
}
}
public void RequestFileUpload(string filePath)
@@ -63,6 +101,24 @@ public sealed class MessageComposerViewModel : ViewModelBase
return _commandHandler?.IsCommand(input) ?? input.StartsWith('/');
}
public void InsertAutocomplete(string item)
{
string? insertion = Autocomplete.GetInsertion(item);
if (insertion is null)
{
return;
}
string before = Draft[..Autocomplete.TriggerCharIndex];
Draft = $"{before}{insertion}";
Autocomplete.Reset();
}
private void OnDraftChanged(string? draft)
{
Autocomplete.Update(draft ?? string.Empty);
}
private void Send()
{
string text = Draft.Trim();
+46 -10
View File
@@ -4,16 +4,52 @@
xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel">
<Grid ColumnDefinitions="*, Auto, Auto" Margin="5">
<TextBox Grid.Column="0"
HorizontalAlignment="Stretch"
Margin="0 0 5 0"
Text="{Binding Draft, Mode=TwoWay}"
IsEnabled="{Binding IsConnected}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid>
<TextBox x:Name="MessageTextBox"
HorizontalAlignment="Stretch"
Margin="0 0 5 0"
Text="{Binding Draft, Mode=TwoWay}"
IsEnabled="{Binding IsConnected}"
KeyDown="OnTextBoxKeyDown">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Popup x:Name="MentionPopup"
Placement="TopEdgeAlignedLeft"
PlacementTarget="{Binding #MessageTextBox}"
IsOpen="{Binding Autocomplete.ShowPopup}"
IsLightDismissEnabled="True"
MinWidth="100"
MaxHeight="140">
<ListBox ItemsSource="{Binding Autocomplete.FilteredItems}"
SelectedIndex="{Binding Autocomplete.SelectedIndex}"
BorderThickness="1"
BorderBrush="{DynamicResource UiTheme08}"
Background="{DynamicResource UiTheme02}">
<ListBox.Styles>
<Style Selector="ListBoxItem:selected">
<Setter Property="Background" Value="#3D3D3D" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}"
FontSize="12"
Padding="4 2"
Cursor="Hand"
PointerPressed="OnMentionPointerPressed" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Popup>
</Grid>
<Button Grid.Column="1"
Height="33"
Width="33"
@@ -1,10 +1,13 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Decho.ViewModels;
using System.Globalization;
namespace Decho.Views;
public partial class MessageComposerView : UserControl
@@ -15,6 +18,104 @@ public partial class MessageComposerView : UserControl
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
DragDrop.SetAllowDrop(this, true);
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is MessageComposerViewModel vm)
{
vm.Autocomplete.PropertyChanged += (_, args) =>
{
if (args.PropertyName is nameof(AutocompleteController.ShowPopup) or nameof(AutocompleteController.FilterText))
{
PositionPopupAtCursor();
}
};
}
}
private void OnTextBoxKeyDown(object? sender, KeyEventArgs e)
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
if (vm is null || !vm.Autocomplete.ShowPopup)
{
return;
}
if (e.Key == Key.Down)
{
vm.Autocomplete.SelectedIndex = Math.Min(vm.Autocomplete.SelectedIndex + 1, vm.Autocomplete.FilteredItems.Count - 1);
e.Handled = true;
}
else if (e.Key == Key.Up)
{
vm.Autocomplete.SelectedIndex = Math.Max(vm.Autocomplete.SelectedIndex - 1, 0);
e.Handled = true;
}
else if (e.Key is Key.Enter or Key.Tab)
{
if (vm.Autocomplete.SelectedIndex >= 0 && vm.Autocomplete.SelectedIndex < vm.Autocomplete.FilteredItems.Count)
{
InsertAutocomplete(vm.Autocomplete.FilteredItems[vm.Autocomplete.SelectedIndex]);
e.Handled = true;
}
}
else if (e.Key == Key.Escape)
{
vm.Autocomplete.ShowPopup = false;
e.Handled = true;
}
}
private void OnMentionPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is TextBlock textBlock && textBlock.DataContext is string item)
{
InsertAutocomplete(item);
}
}
private void InsertAutocomplete(string item)
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
if (vm is null)
{
return;
}
vm.InsertAutocomplete(item);
MessageTextBox.CaretIndex = MessageTextBox.Text?.Length ?? 0;
_ = MessageTextBox.Focus();
}
private void PositionPopupAtCursor()
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
if (vm is null)
{
return;
}
string text = MessageTextBox.Text ?? "";
int targetIndex = Math.Min(vm.Autocomplete.TriggerCharIndex, text.Length);
string beforeTarget = text[..targetIndex];
double width = 0;
if (beforeTarget.Length > 0)
{
FormattedText formatted = new(
beforeTarget,
CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface(MessageTextBox.FontFamily, MessageTextBox.FontStyle, MessageTextBox.FontWeight, MessageTextBox.FontStretch),
MessageTextBox.FontSize,
null);
width = formatted.Width;
}
double maxOffset = Math.Max(0, MessageTextBox.Bounds.Width - 20);
MentionPopup.HorizontalOffset = Math.Min(width + 4, maxOffset);
}
private async void OnFileUploadClicked(object? sender, RoutedEventArgs e)
+2 -2
View File
@@ -23,8 +23,8 @@
FontSize="10"
Margin="5 0 0 0" />
</StackPanel>
<TextBlock VerticalAlignment="Top"
Text="{Binding Content}"
<TextBlock x:Name="MessageContent"
VerticalAlignment="Top"
TextWrapping="Wrap"
IsVisible="{Binding ShowContent}" />
<Image x:Name="MessageImage"
+44
View File
@@ -1,5 +1,7 @@
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
@@ -7,6 +9,8 @@ using Decho.ViewModels;
using EchoHub.Core.DTOs;
using System.Text.RegularExpressions;
namespace Decho.Views;
public partial class MessageItemView : UserControl
@@ -21,6 +25,8 @@ public partial class MessageItemView : UserControl
Loaded += OnLoaded;
}
private static readonly Regex MentionRegex = new(@"@(\w+)", RegexOptions.Compiled);
private void OnDataContextChanged(object? sender, EventArgs args)
{
if (DataContext is MessageViewModel newMsg && newMsg.Model.Id == _loadedMessageId)
@@ -32,6 +38,44 @@ public partial class MessageItemView : UserControl
_loadCts = new CancellationTokenSource();
this.FindControl<Image>("MessageImage")?.ClearValue(Image.SourceProperty);
_loadedMessageId = null;
if (DataContext is MessageViewModel msg)
{
BuildMessageInlines(msg.Content);
}
}
private void BuildMessageInlines(string content)
{
TextBlock? tb = MessageContent;
if (tb is null)
{
return;
}
tb.Inlines?.Clear();
int lastIndex = 0;
foreach (Match match in MentionRegex.Matches(content))
{
if (match.Index > lastIndex)
{
tb.Inlines?.Add(new Run(content[lastIndex..match.Index]));
}
tb.Inlines?.Add(new Run(match.Value)
{
Foreground = new SolidColorBrush(Color.Parse("#FEE75C")),
FontWeight = FontWeight.Bold,
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < content.Length)
{
tb.Inlines?.Add(new Run(content[lastIndex..]));
}
}
private void OnLoaded(object? sender, RoutedEventArgs e)