Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Username autocompletion #82

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
2 changes: 2 additions & 0 deletions src/Blazor.Gitter.Client/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,7 @@ public static async Task Main(string[] args)
.AddSingleton<IChatApi, GitterApi>()
.AddSingleton<ILocalStorageService, LocalStorageService>()
.AddSingleton<ILocalisationHelper, LocalisationHelper>()
.AddSingleton<RoomUsersRepository>()
.AddSingleton<IAppState, AppState>();
builder.RootComponents.Add<App>("app");

Expand Down
3 changes: 3 additions & 0 deletions src/Blazor.Gitter.Core/Components/Pages/Room.razor
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ else
<aside class="chat-room__search @SearchCss">
<RoomSearch ChatRoom="@ThisRoom" UserId="@State.GetMyUser().Id" />
</aside>

<!-- FIXME: technically needs to be positioned inside RoomMessages -->
<RoomUserSearchResults />
</div>
}
12 changes: 12 additions & 0 deletions src/Blazor.Gitter.Core/Components/Shared/AppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public class AppState : IAppState, IDisposable
/// </summary>
public event EventHandler SearchMenuToggled;

public event EventHandler RoomUserSearchCancelled;
public event EventHandler<IEnumerable<IChatUser>> RoomUserSearchPerformed;

public AppState(
ILocalStorageService localStorage,
ILocalisationHelper localisationHelper,
Expand Down Expand Up @@ -188,6 +191,15 @@ public void ToggleSearchMenu()
SearchMenuToggled?.Invoke(this, null);
}

public void CancelRoomUserSearch()
{
RoomUserSearchCancelled?.Invoke(this, null);
}
public void ShowRoomUserSearchResults(IEnumerable<IChatUser> results)
{
RoomUserSearchPerformed?.Invoke(this, results);
}

public bool HasApiKey => !string.IsNullOrWhiteSpace(apiKey);
public string GetApiKey()
{
Expand Down
4 changes: 4 additions & 0 deletions src/Blazor.Gitter.Core/Components/Shared/IAppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ public interface IAppState
event EventHandler<IChatMessage> GotMessageUpdate;
event EventHandler MenuToggled;
event EventHandler SearchMenuToggled;
event EventHandler RoomUserSearchCancelled;
event EventHandler<IEnumerable<IChatUser>> RoomUserSearchPerformed;
bool HasApiKey { get; }
bool HasChatRooms { get; }
bool HasChatUser { get; }
bool Initialised { get; }
void ToggleMenu();
void ToggleSearchMenu();
void CancelRoomUserSearch();
void ShowRoomUserSearchResults(IEnumerable<IChatUser> results);
string GetApiKey();
List<IChatRoom> GetMyRooms();
IChatRoom GetRoom(string RoomId);
Expand Down
12 changes: 8 additions & 4 deletions src/Blazor.Gitter.Core/Components/Shared/RoomMessages.razor.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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; }
Expand Down Expand Up @@ -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
{
Expand All @@ -63,7 +65,7 @@ private void GotMessageUpdate(object sender, IChatMessage message)
}
finally
{
ssFetch.Release();
ssFetch.Release();
}
}
}
Expand All @@ -81,7 +83,7 @@ private void ActivityTimeout(object sender, EventArgs e)
Paused = true;
InvokeAsync(StateHasChanged);
}
catch
catch
{
}
}
Expand Down Expand Up @@ -219,7 +221,9 @@ async Task<int> FetchNewMessages(IChatMessageOptions options, CancellationToken
{
Messages.AddRange(RemoveDuplicates(Messages, messages));
}


await RoomUsersRepository.AddOrUpdateRangeAsync(messages.Select(m => m.FromUser));

await InvokeAsync(StateHasChanged);
await Task.Delay(1);
}
Expand Down
4 changes: 2 additions & 2 deletions src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<textarea id="@MessageInputId"
rows="@Rows"
class="@NewMessageClass"
@bind-value="NewMessage"
@bind-value:event="oninput"
value="@NewMessage"
@oninput="OnInput"
@onkeydown="Shortcuts"
placeholder="Ctrl-Enter to send..." />
<button id="@OkButtonId" type="submit" class="chat-room__send-button" @onclick="SendMessage">Ok</button>
Expand Down
91 changes: 90 additions & 1 deletion src/Blazor.Gitter.Core/Components/Shared/RoomSend.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using System;
using System.Linq;
using System.Threading.Tasks;

#nullable enable

namespace Blazor.Gitter.Core.Components.Shared
{
public class RoomSendBase : ComponentBase, IDisposable
Expand All @@ -14,8 +17,12 @@ 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; }

private string newMessage = "";
internal string NewMessage
{
get => newMessage;
Expand All @@ -25,8 +32,57 @@ internal string NewMessage
}
}

private readonly DebounceHelper debouncer = new DebounceHelper();
protected async Task OnInput(ChangeEventArgs e)
{
NewMessage = e.Value as string ?? "";

if (_IsShowingUsernameAutocomplete)
{
await debouncer.DebounceAsync(async (cancellationToken) =>
{
await QueryRoomUsersAsync();
}, delayMilliseconds: 300);
}
}

private async Task QueryRoomUsersAsync()
{
if (!_IsShowingUsernameAutocomplete || !NewMessage.Contains("@"))
{
State.CancelRoomUserSearch();
_IsShowingUsernameAutocomplete = false;
return;
}

string query = NewMessage.Substring(_UsernameAutocompleteStartIndex);

Console.WriteLine($"Should pop up! Will query: {query}");

if (string.IsNullOrWhiteSpace(query))
return;

var chatRoomUsers = await RoomUsersRepository.QueryAsync(this.ChatRoom, query);

if (chatRoomUsers.Any())
{
State.ShowRoomUserSearchResults(chatRoomUsers);
}
else
{
State.CancelRoomUserSearch();
}

// TODO:
// * allow arrow up/down selection

foreach (var item in chatRoomUsers)
{
Console.WriteLine(item.DisplayName);
}
}

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";
Expand Down Expand Up @@ -109,6 +165,9 @@ void QuoteMessage(object sender, ChatMessageEventArgs e)
Task.Delay(1);
}

private bool _IsShowingUsernameAutocomplete;
private int _UsernameAutocompleteStartIndex;

internal async Task Shortcuts(KeyboardEventArgs args)
{
if (args.CtrlKey)
Expand All @@ -123,6 +182,36 @@ 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
// note that NewMessage represents the state _before_ oninput
if (selectionStart < 0 ||
NewMessage.Length == 0 ||
(selectionStart == 0 && char.IsWhiteSpace(NewMessage[selectionStart + 1])) ||
(selectionStart == NewMessage.Length && char.IsWhiteSpace(NewMessage[selectionStart - 1])))
{
_IsShowingUsernameAutocomplete = true;
_UsernameAutocompleteStartIndex = selectionStart + 1;
}

break;

case "Escape":
_IsShowingUsernameAutocomplete = false;
State.CancelRoomUserSearch();

break;
}

}
return;
}
public void Dispose()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@inherits RoomUserSearchResultsBase

@if (IsVisible)
{
<div class="chat-room__roomusersearchresults">
@foreach (var item in Results)
{
<div>@item.DisplayName <small>@@@item.Username</small></div>
}
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Blazor.Gitter.Core.Browser;
using Blazor.Gitter.Library;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

#nullable enable

namespace Blazor.Gitter.Core.Components.Shared
{
public class RoomUserSearchResultsBase : ComponentBase
{
[Inject] IAppState State { get; set; }
[Inject] IJSRuntime JSRuntime { get; set; }

protected IEnumerable<IChatUser> Results { get; set; } = Enumerable.Empty<IChatUser>();

protected bool IsVisible { get; set; }

protected override void OnInitialized()
{
base.OnInitialized();
State.RoomUserSearchCancelled += SearchCancelled;
State.RoomUserSearchPerformed += SearchPerformed;
}

private async void SearchCancelled(object sender, EventArgs e)
{
IsVisible = false;

await InvokeAsync(StateHasChanged);
}

private async void SearchPerformed(object sender, IEnumerable<IChatUser> results)
{
Results = results;

IsVisible = true;

await InvokeAsync(StateHasChanged);

await BrowserInterop.RepositionRoomSearchResults(JSRuntime);
}
}
}
29 changes: 29 additions & 0 deletions src/Blazor.Gitter.Core/Services/DebounceHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Blazor.Gitter.Core
{
public class DebounceHelper
{
private CancellationTokenSource debounceToken = null;

public async Task DebounceAsync(Func<CancellationToken, Task> func, int delayMilliseconds = 1000)
{
// https://stackoverflow.com/a/62196612/

// Cancel previous task
if (debounceToken != null) { debounceToken.Cancel(); }

// Assign new token
debounceToken = new CancellationTokenSource();

await Task.Delay(delayMilliseconds, debounceToken.Token);

if (debounceToken.IsCancellationRequested)
return;

await func(debounceToken.Token);
}
}
}
12 changes: 10 additions & 2 deletions src/Blazor.Gitter.Core/browser/BrowserInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ namespace Blazor.Gitter.Core.Browser
{
static class BrowserInterop
{
public static ValueTask RepositionRoomSearchResults(this IJSRuntime jsRuntime)
=> jsRuntime.InvokeVoidAsync("chat.repositionRoomSearchResults");

public static ValueTask<int> GetSelectionStart(this IJSRuntime JSRuntime, string id)
{
return JSRuntime.InvokeAsync<int>("chat.getSelectionStart", id);
}

public static ValueTask<double> GetScrollTop(this IJSRuntime JSRuntime, string id)
{
return JSRuntime.InvokeAsync<double>("chat.getScrollTop",id);
return JSRuntime.InvokeAsync<double>("chat.getScrollTop", id);
}
public static ValueTask<bool> IsScrolledToBottom(this IJSRuntime JSRuntime, string id)
{
Expand All @@ -27,7 +35,7 @@ public static ValueTask<bool> ScrollIntoView(this IJSRuntime JSRuntime, string i
{
return JSRuntime.InvokeAsync<bool>("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<bool> SetFocus(this IJSRuntime JSRuntime, ElementReference elementRef)
{
return JSRuntime.InvokeAsync<bool>("chat.setFocus", elementRef);
Expand Down
8 changes: 8 additions & 0 deletions src/Blazor.Gitter.Core/content/css/_chatroom.scss
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,11 @@
font-style: italic;
}

.chat-room__roomusersearchresults {
position: absolute;
z-index: 2;
bottom: 0;
padding: 1em;
width: 300px;
background-color: $background-lighter;
}
Loading