Added typing indicators
This commit is contained in:
@@ -11,6 +11,9 @@ public class SupportAdminService
|
|||||||
|
|
||||||
public EventHandler<SupportMessage> OnNewMessage;
|
public EventHandler<SupportMessage> OnNewMessage;
|
||||||
|
|
||||||
|
public EventHandler OnUpdateTyping;
|
||||||
|
private List<string> TypingUsers = new();
|
||||||
|
|
||||||
private User Self;
|
private User Self;
|
||||||
private User Recipient;
|
private User Recipient;
|
||||||
|
|
||||||
@@ -38,6 +41,48 @@ public class SupportAdminService
|
|||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
MessageService.Subscribe<SupportClientService, User>(
|
||||||
|
$"support.{Self.Id}.typing",
|
||||||
|
this,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
HandleTyping(user);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleTyping(User user)
|
||||||
|
{
|
||||||
|
var name = $"{user.FirstName} {user.LastName}";
|
||||||
|
|
||||||
|
lock (TypingUsers)
|
||||||
|
{
|
||||||
|
if (!TypingUsers.Contains(name))
|
||||||
|
{
|
||||||
|
TypingUsers.Add(name);
|
||||||
|
OnUpdateTyping!.Invoke(this, null!);
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (TypingUsers.Contains(name))
|
||||||
|
{
|
||||||
|
TypingUsers.Remove(name);
|
||||||
|
OnUpdateTyping!.Invoke(this, null!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] GetTypingUsers()
|
||||||
|
{
|
||||||
|
lock (TypingUsers)
|
||||||
|
{
|
||||||
|
return TypingUsers.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SupportMessage[]> GetMessages()
|
public async Task<SupportMessage[]> GetMessages()
|
||||||
@@ -65,8 +110,19 @@ public class SupportAdminService
|
|||||||
await SupportServerService.Close(Recipient);
|
await SupportServerService.Close(Recipient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task TriggerTyping()
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await MessageService.Emit($"support.{Recipient.Id}.admintyping", Self);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
MessageService.Unsubscribe($"support.{Recipient.Id}.message", this);
|
MessageService.Unsubscribe($"support.{Recipient.Id}.message", this);
|
||||||
|
MessageService.Unsubscribe($"support.{Recipient.Id}.typing", this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,9 @@ public class SupportClientService : IDisposable
|
|||||||
private readonly MessageService MessageService;
|
private readonly MessageService MessageService;
|
||||||
|
|
||||||
public EventHandler<SupportMessage> OnNewMessage;
|
public EventHandler<SupportMessage> OnNewMessage;
|
||||||
|
|
||||||
|
public EventHandler OnUpdateTyping;
|
||||||
|
private List<string> TypingUsers = new();
|
||||||
|
|
||||||
private User Self;
|
private User Self;
|
||||||
|
|
||||||
@@ -36,6 +39,48 @@ public class SupportClientService : IDisposable
|
|||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
MessageService.Subscribe<SupportClientService, User>(
|
||||||
|
$"support.{Self.Id}.admintyping",
|
||||||
|
this,
|
||||||
|
user =>
|
||||||
|
{
|
||||||
|
HandleTyping(user);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleTyping(User user)
|
||||||
|
{
|
||||||
|
var name = $"{user.FirstName} {user.LastName}";
|
||||||
|
|
||||||
|
lock (TypingUsers)
|
||||||
|
{
|
||||||
|
if (!TypingUsers.Contains(name))
|
||||||
|
{
|
||||||
|
TypingUsers.Add(name);
|
||||||
|
OnUpdateTyping!.Invoke(this, null!);
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (TypingUsers.Contains(name))
|
||||||
|
{
|
||||||
|
TypingUsers.Remove(name);
|
||||||
|
OnUpdateTyping!.Invoke(this, null!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] GetTypingUsers()
|
||||||
|
{
|
||||||
|
lock (TypingUsers)
|
||||||
|
{
|
||||||
|
return TypingUsers.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SupportMessage[]> GetMessages()
|
public async Task<SupportMessage[]> GetMessages()
|
||||||
@@ -56,9 +101,20 @@ public class SupportClientService : IDisposable
|
|||||||
Self
|
Self
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task TriggerTyping()
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await MessageService.Emit($"support.{Self.Id}.typing", Self);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
MessageService.Unsubscribe($"support.{Self.Id}.message", this);
|
MessageService.Unsubscribe($"support.{Self.Id}.message", this);
|
||||||
|
MessageService.Unsubscribe($"support.{Self.Id}.admintyping", this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
|
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
|
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
|
||||||
@if (message.IsSystem)
|
@if (message.IsSystem)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
|
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
|
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
|
||||||
@(message.Message)
|
@(message.Message)
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +92,34 @@
|
|||||||
</LazyLoader>
|
</LazyLoader>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<textarea @bind="Content" class="form-control form-control-flush mb-3" rows="1" placeholder="Type a message">
|
@{
|
||||||
</textarea>
|
var typingUsers = SupportAdminService.GetTypingUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (typingUsers.Any())
|
||||||
|
{
|
||||||
|
<span class="mb-5 fs-5 d-flex flex-row">
|
||||||
|
<div class="wave me-3">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
</div>
|
||||||
|
@if (typingUsers.Length > 1)
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
@(typingUsers.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
@(typingUsers.First()) <TL>is typing</TL>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3" rows="1" placeholder="Type a message">
|
||||||
|
</textarea>
|
||||||
<div class="d-flex flex-stack">
|
<div class="d-flex flex-stack">
|
||||||
<div class="d-flex align-items-center me-2">
|
<div class="d-flex align-items-center me-2">
|
||||||
<button class="btn btn-sm btn-icon btn-active-light-primary me-1" type="button">
|
<button class="btn btn-sm btn-icon btn-active-light-primary me-1" type="button">
|
||||||
@@ -114,7 +140,7 @@
|
|||||||
<h2 class="text-dark fw-bold mb-11">
|
<h2 class="text-dark fw-bold mb-11">
|
||||||
<TL>User information</TL>
|
<TL>User information</TL>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-6">
|
<div class="d-flex align-items-center mb-6">
|
||||||
<spna class="fw-semibold text-gray-800 fs-5 m-0">
|
<spna class="fw-semibold text-gray-800 fs-5 m-0">
|
||||||
<TL>Firstname</TL>: @(User.FirstName)
|
<TL>Firstname</TL>: @(User.FirstName)
|
||||||
@@ -156,6 +182,8 @@
|
|||||||
private SupportMessage[] Messages;
|
private SupportMessage[] Messages;
|
||||||
private string Content = "";
|
private string Content = "";
|
||||||
|
|
||||||
|
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
|
||||||
|
|
||||||
private async Task Load(LazyLoader arg)
|
private async Task Load(LazyLoader arg)
|
||||||
{
|
{
|
||||||
User = UserRepository
|
User = UserRepository
|
||||||
@@ -165,11 +193,17 @@
|
|||||||
if (User != null)
|
if (User != null)
|
||||||
{
|
{
|
||||||
SupportAdminService.OnNewMessage += OnNewMessage;
|
SupportAdminService.OnNewMessage += OnNewMessage;
|
||||||
|
SupportAdminService.OnUpdateTyping += OnUpdateTyping;
|
||||||
|
|
||||||
await SupportAdminService.Start(User);
|
await SupportAdminService.Start(User);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnUpdateTyping(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnNewMessage(object? sender, SupportMessage e)
|
private async void OnNewMessage(object? sender, SupportMessage e)
|
||||||
{
|
{
|
||||||
Messages = (await SupportAdminService.GetMessages()).Reverse().ToArray();
|
Messages = (await SupportAdminService.GetMessages()).Reverse().ToArray();
|
||||||
@@ -191,4 +225,14 @@
|
|||||||
{
|
{
|
||||||
await SupportAdminService.Close();
|
await SupportAdminService.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OnTyping()
|
||||||
|
{
|
||||||
|
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
|
||||||
|
{
|
||||||
|
LastTypingTimestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await SupportAdminService.TriggerTyping();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,30 @@
|
|||||||
</LazyLoader>
|
</LazyLoader>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<textarea @bind="Content" class="form-control form-control-flush mb-3" rows="1" placeholder="Type a message">
|
@{
|
||||||
|
var typingUsers = SupportClientService.GetTypingUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (typingUsers.Any())
|
||||||
|
{
|
||||||
|
<span class="mb-5 fs-5 d-flex flex-row">
|
||||||
|
<div class="wave me-3">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
</div>
|
||||||
|
@if (typingUsers.Length > 1)
|
||||||
|
{
|
||||||
|
<span>@(typingUsers.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@(typingUsers.First()) <TL>is typing</TL></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<textarea @bind="Content" @oninput="OnTyping" class="form-control mb-3" rows="1" placeholder="Type a message">
|
||||||
</textarea>
|
</textarea>
|
||||||
<div class="d-flex flex-stack">
|
<div class="d-flex flex-stack">
|
||||||
<div class="d-flex align-items-center me-2">
|
<div class="d-flex align-items-center me-2">
|
||||||
@@ -124,6 +147,8 @@
|
|||||||
|
|
||||||
private SupportMessage[] Messages;
|
private SupportMessage[] Messages;
|
||||||
private string Content = "";
|
private string Content = "";
|
||||||
|
|
||||||
|
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
|
||||||
|
|
||||||
private async Task Load(LazyLoader lazyLoader)
|
private async Task Load(LazyLoader lazyLoader)
|
||||||
{
|
{
|
||||||
@@ -132,10 +157,16 @@
|
|||||||
await lazyLoader.SetText("Starting chat client");
|
await lazyLoader.SetText("Starting chat client");
|
||||||
|
|
||||||
SupportClientService.OnNewMessage += OnNewMessage;
|
SupportClientService.OnNewMessage += OnNewMessage;
|
||||||
|
SupportClientService.OnUpdateTyping += OnUpdateTyping;
|
||||||
|
|
||||||
await SupportClientService.Start();
|
await SupportClientService.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnUpdateTyping(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnNewMessage(object? sender, SupportMessage e)
|
private async void OnNewMessage(object? sender, SupportMessage e)
|
||||||
{
|
{
|
||||||
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
|
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
|
||||||
@@ -153,4 +184,14 @@
|
|||||||
{
|
{
|
||||||
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
|
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnTyping()
|
||||||
|
{
|
||||||
|
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
|
||||||
|
{
|
||||||
|
LastTypingTimestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await SupportClientService.TriggerTyping();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -224,3 +224,6 @@ Close ticket;Close ticket
|
|||||||
Closing;Closing
|
Closing;Closing
|
||||||
The support team has been notified. Please be patient;The support team has been notified. Please be patient
|
The support team has been notified. Please be patient;The support team has been notified. Please be patient
|
||||||
The ticket is now closed. Type a message to open it again;The ticket is now closed. Type a message to open it again
|
The ticket is now closed. Type a message to open it again;The ticket is now closed. Type a message to open it again
|
||||||
|
1 day ago;1 day ago
|
||||||
|
is typing;is typing
|
||||||
|
are typing;are typing
|
||||||
|
|||||||
@@ -14,4 +14,31 @@
|
|||||||
|
|
||||||
.blur-unless-hover:hover {
|
.blur-unless-hover:hover {
|
||||||
filter: none;
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.wave {
|
||||||
|
}
|
||||||
|
div.wave .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 3px;
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
animation: wave 1.3s linear infinite;
|
||||||
|
}
|
||||||
|
div.wave .dot:nth-child(2) {
|
||||||
|
animation-delay: -1.1s;
|
||||||
|
}
|
||||||
|
div.wave .dot:nth-child(3) {
|
||||||
|
animation-delay: -0.9s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wave {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
transform: initial;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user