mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46:11 +02:00
Add chat composer autocomplete for mentions and channels
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
Submodule src/EchoHub updated: aae788028e...b62729dc95
Reference in New Issue
Block a user