diff --git a/src/Decho/ViewModels/AutocompleteController.cs b/src/Decho/ViewModels/AutocompleteController.cs new file mode 100644 index 0000000..8db20e6 --- /dev/null +++ b/src/Decho/ViewModels/AutocompleteController.cs @@ -0,0 +1,103 @@ +using System.Collections.ObjectModel; +using System.Text.RegularExpressions; + +namespace Decho.ViewModels; + +public sealed class AutocompleteController : ViewModelBase +{ + private readonly List _providers; + + private static readonly Regex TriggerPattern = new(@"([@#])(\w*)$", RegexOptions.Compiled); + + public ObservableCollection 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 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 items = provider.ItemsSource().ToList(); + List 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(); + } +} \ No newline at end of file diff --git a/src/Decho/ViewModels/AutocompleteProvider.cs b/src/Decho/ViewModels/AutocompleteProvider.cs new file mode 100644 index 0000000..a1d8681 --- /dev/null +++ b/src/Decho/ViewModels/AutocompleteProvider.cs @@ -0,0 +1,23 @@ +namespace Decho.ViewModels; + +public sealed class AutocompleteProvider +{ + public char Trigger { get; } + public Func> ItemsSource { get; } + public Func Filter { get; } + public Func FormatInsertion { get; } + public int MaxResults { get; } + + public AutocompleteProvider( + char trigger, + Func> itemsSource, + string insertPrefix, + int maxResults = 10) + { + Trigger = trigger; + ItemsSource = itemsSource; + Filter = (item, filter) => item.StartsWith(filter, StringComparison.OrdinalIgnoreCase); + FormatInsertion = item => $"{insertPrefix}{item} "; + MaxResults = maxResults; + } +} \ No newline at end of file diff --git a/src/Decho/ViewModels/ChatViewModel.cs b/src/Decho/ViewModels/ChatViewModel.cs index b18b2d0..3a2869f 100644 --- a/src/Decho/ViewModels/ChatViewModel.cs +++ b/src/Decho/ViewModels/ChatViewModel.cs @@ -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() diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index 4b4fb2e..1bcb437 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -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()); diff --git a/src/Decho/ViewModels/MessageComposerViewModel.cs b/src/Decho/ViewModels/MessageComposerViewModel.cs index a526956..cd54a43 100644 --- a/src/Decho/ViewModels/MessageComposerViewModel.cs +++ b/src/Decho/ViewModels/MessageComposerViewModel.cs @@ -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 _onlineUsers = []; + private readonly ObservableCollection _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 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 users) + { + _onlineUsers.Clear(); + foreach (UserViewModel user in users) + { + _onlineUsers.Add(user); + } + } + + public void UpdateAvailableChannels(IEnumerable 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(); diff --git a/src/Decho/Views/MessageComposerView.axaml b/src/Decho/Views/MessageComposerView.axaml index 7a638a0..fb5e191 100644 --- a/src/Decho/Views/MessageComposerView.axaml +++ b/src/Decho/Views/MessageComposerView.axaml @@ -4,16 +4,52 @@ xmlns:i="https://github.com/projektanker/icons.avalonia" x:Class="Decho.Views.MessageComposerView" x:DataType="vm:MessageComposerViewModel"> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +