From fc3ba1d2544703994586ffd702c64f422930621e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Wed, 3 Jun 2020 19:52:24 +0200 Subject: [PATCH 01/12] username-autocomplete: get cursor pos via JS --- src/Blazor.Gitter.Core/content/scripts/chat.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Blazor.Gitter.Core/content/scripts/chat.ts b/src/Blazor.Gitter.Core/content/scripts/chat.ts index 81d4d9c..0ddfbd6 100644 --- a/src/Blazor.Gitter.Core/content/scripts/chat.ts +++ b/src/Blazor.Gitter.Core/content/scripts/chat.ts @@ -1,4 +1,15 @@ (window).chat = { + getSelectionStart: function (id: string) { + const el = document.getElementById(id) as HTMLInputElement; + try { + if (el) { + return el.selectionStart; + } + } + catch { } + + return -1; + }, getScrollTop: function (id: string) { const el = document.getElementById(id); try { From 17d5621c103a57be78aac4d250790df731ff6bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Wed, 3 Jun 2020 19:52:24 +0200 Subject: [PATCH 02/12] username-autocomplete: get cursor pos via JS --- src/Blazor.Gitter.Core/browser/BrowserInterop.cs | 9 +++++++-- src/Blazor.Gitter.Core/content/scripts/chat.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Blazor.Gitter.Core/browser/BrowserInterop.cs b/src/Blazor.Gitter.Core/browser/BrowserInterop.cs index 7f69209..503d385 100644 --- a/src/Blazor.Gitter.Core/browser/BrowserInterop.cs +++ b/src/Blazor.Gitter.Core/browser/BrowserInterop.cs @@ -7,9 +7,14 @@ namespace Blazor.Gitter.Core.Browser { static class BrowserInterop { + public static ValueTask GetSelectionStart(this IJSRuntime JSRuntime, string id) + { + return JSRuntime.InvokeAsync("chat.getSelectionStart", id); + } + public static ValueTask GetScrollTop(this IJSRuntime JSRuntime, string id) { - return JSRuntime.InvokeAsync("chat.getScrollTop",id); + return JSRuntime.InvokeAsync("chat.getScrollTop", id); } public static ValueTask IsScrolledToBottom(this IJSRuntime JSRuntime, string id) { @@ -27,7 +32,7 @@ public static ValueTask ScrollIntoView(this IJSRuntime JSRuntime, string i { return JSRuntime.InvokeAsync("chat.scrollIntoView", id); } - [Obsolete("Please use SetFocusById now as there is a bug in the JSInterop",true)] + [Obsolete("Please use SetFocusById now as there is a bug in the JSInterop", true)] public static ValueTask SetFocus(this IJSRuntime JSRuntime, ElementReference elementRef) { return JSRuntime.InvokeAsync("chat.setFocus", elementRef); diff --git a/src/Blazor.Gitter.Core/content/scripts/chat.ts b/src/Blazor.Gitter.Core/content/scripts/chat.ts index 81d4d9c..0ddfbd6 100644 --- a/src/Blazor.Gitter.Core/content/scripts/chat.ts +++ b/src/Blazor.Gitter.Core/content/scripts/chat.ts @@ -1,4 +1,15 @@ (window).chat = { + getSelectionStart: function (id: string) { + const el = document.getElementById(id) as HTMLInputElement; + try { + if (el) { + return el.selectionStart; + } + } + catch { } + + return -1; + }, getScrollTop: function (id: string) { const el = document.getElementById(id); try { From ed50e3e885261159f0bf3548bc03c1d955525199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Wed, 3 Jun 2020 20:19:49 +0200 Subject: [PATCH 03/12] username-autocomplete: Implement user cache This doesn't actually query the cache yet. For that, we need an appropriate UI. It does, however, fill it at opportune times: whenever messages come in, and when you query. --- src/Blazor.Gitter.Client/Program.cs | 2 + .../Components/Shared/RoomMessages.razor.cs | 12 +- .../Components/Shared/RoomSend.razor.cs | 2 + .../Blazor.Gitter.Library.csproj | 1 + .../Services/RoomUsersRepository.cs | 109 ++++++++++++++++++ src/Blazor.Gitter.Server/Startup.cs | 2 + 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/Blazor.Gitter.Library/Services/RoomUsersRepository.cs diff --git a/src/Blazor.Gitter.Client/Program.cs b/src/Blazor.Gitter.Client/Program.cs index c3d9b3f..fc47829 100644 --- a/src/Blazor.Gitter.Client/Program.cs +++ b/src/Blazor.Gitter.Client/Program.cs @@ -5,6 +5,7 @@ using Blazor.Gitter.Core.Components; using Blazor.Gitter.Core.Components.Shared; using Blazor.Gitter.Library; +using Blazor.Gitter.Library.Services; using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,7 @@ public static async Task Main(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton(); builder.RootComponents.Add("app"); diff --git a/src/Blazor.Gitter.Core/Components/Shared/RoomMessages.razor.cs b/src/Blazor.Gitter.Core/Components/Shared/RoomMessages.razor.cs index 69ba35b..2b2fd81 100644 --- a/src/Blazor.Gitter.Core/Components/Shared/RoomMessages.razor.cs +++ b/src/Blazor.Gitter.Core/Components/Shared/RoomMessages.razor.cs @@ -1,5 +1,6 @@ using Blazor.Gitter.Core.Browser; using Blazor.Gitter.Library; +using Blazor.Gitter.Library.Services; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using System; @@ -16,6 +17,7 @@ public class RoomMessagesBase : ComponentBase, IDisposable [Inject] IChatApi GitterApi { get; set; } [Inject] ILocalisationHelper Localisation { get; set; } [Inject] IAppState State { get; set; } + [Inject] RoomUsersRepository RoomUsersRepository { get; set; } // maybe inject this per-room? [Parameter] public IChatRoom ChatRoom { get; set; } [Parameter] public string UserId { get; set; } @@ -50,7 +52,7 @@ private void GotMessageFilter(object sender, IChatMessageFilter filter) private void GotMessageUpdate(object sender, IChatMessage message) { - if (ssFetch.Wait(-1,tokenSource.Token)) + if (ssFetch.Wait(-1, tokenSource.Token)) { try { @@ -63,7 +65,7 @@ private void GotMessageUpdate(object sender, IChatMessage message) } finally { - ssFetch.Release(); + ssFetch.Release(); } } } @@ -81,7 +83,7 @@ private void ActivityTimeout(object sender, EventArgs e) Paused = true; InvokeAsync(StateHasChanged); } - catch + catch { } } @@ -219,7 +221,9 @@ async Task FetchNewMessages(IChatMessageOptions options, CancellationToken { Messages.AddRange(RemoveDuplicates(Messages, messages)); } - + + await RoomUsersRepository.AddOrUpdateRangeAsync(messages.Select(m => m.FromUser)); + await InvokeAsync(StateHasChanged); await Task.Delay(1); } diff --git a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs index c0b790d..dfd4163 100644 --- a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs +++ b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs @@ -14,6 +14,8 @@ public class RoomSendBase : ComponentBase, IDisposable [Inject] IAppState State { get; set; } [Inject] IJSRuntime JSRuntime { get; set; } + [Inject] Library.Services.RoomUsersRepository RoomUsersRepository { get; set; } + [Parameter] public IChatRoom ChatRoom { get; set; } [Parameter] public IChatUser User { get; set; } internal string NewMessage diff --git a/src/Blazor.Gitter.Library/Blazor.Gitter.Library.csproj b/src/Blazor.Gitter.Library/Blazor.Gitter.Library.csproj index 2deb10e..85b9ef5 100644 --- a/src/Blazor.Gitter.Library/Blazor.Gitter.Library.csproj +++ b/src/Blazor.Gitter.Library/Blazor.Gitter.Library.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Blazor.Gitter.Library/Services/RoomUsersRepository.cs b/src/Blazor.Gitter.Library/Services/RoomUsersRepository.cs new file mode 100644 index 0000000..ba095b3 --- /dev/null +++ b/src/Blazor.Gitter.Library/Services/RoomUsersRepository.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; + +namespace Blazor.Gitter.Library.Services +{ + public abstract class CachedRepository + { + MemoryCache _Cache; + + public CachedRepository() + { + _Cache = new MemoryCache(new MemoryCacheOptions()); + } + + protected abstract string _MakeKey(T item); + + public async Task AddOrUpdateRangeAsync(IEnumerable enumerable) + { + foreach (var item in enumerable) + { + await AddOrUpdateAsync(item); + } + + Console.WriteLine($"User cache count: {_Cache.Count}"); + } + + private async Task AddOrUpdateAsync(T item) + { + await _Cache.GetOrCreateAsync(_MakeKey(item), entry => + { + entry.SlidingExpiration = TimeSpan.FromMinutes(5); + return Task.FromResult(item); + }); + } + + protected IReadOnlyCollection _GetCurrentEntries() + { + // UGLY: approaching this with reflection isn't great. + + var field = typeof(MemoryCache).GetProperty("EntriesCollection", BindingFlags.NonPublic | BindingFlags.Instance); + var collection = field.GetValue(_Cache) as ICollection; + + var entries = new List(); + + PropertyInfo kvpValueProperty = null; + + if (collection != null) + foreach (var item in collection) + { + if (kvpValueProperty == null) + kvpValueProperty = item.GetType().GetProperty("Value"); + + // TODO MAYBE: return newest entries first? + + if ((kvpValueProperty.GetValue(item) as ICacheEntry)?.Value is T value) + entries.Add(value); + } + + return entries.AsReadOnly(); + } + } + + /// + /// Stores a local cache of known instances. It gets + /// filled whenever new messages come in, users are queried, etc., and has a + /// sliding expiration. + /// + /// This is done mainly to work around + /// being unreliable. + /// + public class RoomUsersRepository : CachedRepository + { + private readonly IChatApi _GitterApi; + + public RoomUsersRepository(IChatApi gitterApi) + => _GitterApi = gitterApi; + + /// + /// Query the cache and the API (in turn adding to the cache) for a + /// user by partial or + /// . + /// + /// If the query is empty, we just display some of the cache. + /// + /// Skip querying the API + public async Task> QueryAsync(IChatRoom room, string query, bool onlyQueryTheCache = false) + { + // BUG: room is actually ignored for cache. If you're in multiple + // rooms, this will suggest users who aren't here. + + if (!onlyQueryTheCache && !(string.IsNullOrWhiteSpace(query))) + { + var users = await _GitterApi.GetChatRoomUsers(room.Id, new GitterRoomUserOptions { Query = query }); + + await AddOrUpdateRangeAsync(users); + } + + return _GetCurrentEntries().Where(u => u.Username.Contains(query, StringComparison.OrdinalIgnoreCase) || u.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + + protected override string _MakeKey(IChatUser item) + => $"User_{item.Id}"; + } +} diff --git a/src/Blazor.Gitter.Server/Startup.cs b/src/Blazor.Gitter.Server/Startup.cs index d68a278..6afd710 100644 --- a/src/Blazor.Gitter.Server/Startup.cs +++ b/src/Blazor.Gitter.Server/Startup.cs @@ -1,6 +1,7 @@ using Blazor.Gitter.Core; using Blazor.Gitter.Core.Components.Shared; using Blazor.Gitter.Library; +using Blazor.Gitter.Library.Services; using Blazored.LocalStorage; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Server; @@ -31,6 +32,7 @@ public void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped(); } From 90393e47460ace4c38fa5ef632548aa317547192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Wed, 3 Jun 2020 20:26:17 +0200 Subject: [PATCH 04/12] username-autocomplete: _very_ early UI stuff --- .../Components/Shared/RoomSend.razor.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs index dfd4163..8e3c86b 100644 --- a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs +++ b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs @@ -125,6 +125,46 @@ internal async Task Shortcuts(KeyboardEventArgs args) break; } } + else + { + switch (args.Key) + { + case "@": + var selectionStart = await BrowserInterop.GetSelectionStart(JSRuntime, MessageInputId); + + Console.WriteLine($"Selection start: {selectionStart}"); + + // the input field only contains '@', or there's whitespace next to our caret + + if (selectionStart < 0 || + NewMessage.Length == 1 || + (selectionStart == 0 && char.IsWhiteSpace(NewMessage[selectionStart + 1])) || + (selectionStart == NewMessage.Length && char.IsWhiteSpace(NewMessage[selectionStart - 1]))) + { + string query = NewMessage.Substring(selectionStart); + + Console.WriteLine($"Should pop up! Will query: {query}"); + + var chatRoomUsers = await RoomUsersRepository.QueryAsync(this.ChatRoom, query); + + // TODO: + // * display result in popup + // * allow arrow up/down selection + + foreach (var item in chatRoomUsers) + { + Console.WriteLine(item.DisplayName); + } + } + break; + + case "Escape": + // TODO: close popup + + break; + } + + } return; } public void Dispose() From 097c5c5de959a75be2f2fff64b3957c9c1edc5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Thu, 4 Jun 2020 21:00:51 +0200 Subject: [PATCH 05/12] username-autocomplete: newMessage should never be null --- src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs index 8e3c86b..72e473d 100644 --- a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs +++ b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs @@ -18,6 +18,8 @@ public class RoomSendBase : ComponentBase, IDisposable [Parameter] public IChatRoom ChatRoom { get; set; } [Parameter] public IChatUser User { get; set; } + + private string newMessage = ""; internal string NewMessage { get => newMessage; @@ -28,7 +30,6 @@ internal string NewMessage } private const string BaseClass = "chat-room__send-message"; - private string newMessage; internal string NewMessageClass = BaseClass; internal string OkButtonId = "message-send-button"; internal string MessageInputId = "message-send-input"; From 5bb4ad5e5f83af0714381cb40bdbec8fc5309b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=CC=88ren=20Kuklau?= Date: Thu, 4 Jun 2020 21:02:19 +0200 Subject: [PATCH 06/12] username-autocomplete: NewMessage is no longer two-way-bound so we can use `await` --- src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor | 4 ++-- src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor index bec5008..154f507 100644 --- a/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor +++ b/src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor @@ -5,8 +5,8 @@