зеркало из
1
0
Форкнуть 0
* Add ChatRoomWithAck sample.

Add ChatRoomWithAck sample.
Sending a message to a specified user will log the status (Sent, Arrived, Acknowledged) on the chat screen.
The user who received the message can ack each message by clicking it.
The server now stores the message for offline users. They will receive these messages the next time they login into this page with the same userName.

* Address coding standards and update 'message' classes

* Address coding convention issues

* Resharper the code

* DI the message handler

* Add jwt authentication

* Revert "Add jwt authentication"

This reverts commit 9e6a436444.

* DI the AckHandler and remove some old classes

* Address AckHandler related issues

* Refactor the hub classes

* Update Index and Add comments

* Add AckableChatRoom sample

* Self review

* Self review

* Remove the enum and update the hub

* Update hub name

* Update hub name

* Update 'Read' status logic

* Update front-end
This commit is contained in:
Lixiang 2021-12-08 14:19:19 +08:00 коммит произвёл GitHub
Родитель 0df8cb4756
Коммит e4cad4c928
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 638 добавлений и 0 удалений

6
samples/AckableChatRoom/.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,6 @@
bin/
obj/
.vs/
**.csproj.user
*.sln
*.Dotsettings.user

Просмотреть файл

@ -0,0 +1,73 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using System.Threading;
using Microsoft.AspNetCore.SignalR;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public class AckHandler : IAckHandler, IDisposable
{
private readonly ConcurrentDictionary<string, (TaskCompletionSource<string>, DateTime)> _handlers
= new ConcurrentDictionary<string, (TaskCompletionSource<string>, DateTime)>();
private readonly TimeSpan _ackThreshold;
private readonly Timer _timer;
public AckHandler() : this(
completeAcksOnTimeout: true,
ackThreshold: TimeSpan.FromSeconds(5),
ackInterval: TimeSpan.FromSeconds(1))
{
}
public AckHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval)
{
if (completeAcksOnTimeout)
{
_timer = new Timer(_ => CheckAcks(), state: null, dueTime: ackInterval, period: ackInterval);
}
_ackThreshold = ackThreshold;
}
public AckInfo CreateAck()
{
var id = Guid.NewGuid().ToString();
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_handlers.TryAdd(id, (tcs, DateTime.UtcNow));
return new AckInfo(id, tcs.Task);
}
public void Ack(string id)
{
if (_handlers.TryRemove(id, out var res))
{
res.Item1.TrySetResult("Sent");
}
}
public void Dispose()
{
_timer?.Dispose();
foreach (var pair in _handlers)
{
pair.Value.Item1.TrySetCanceled();
}
}
private void CheckAcks()
{
foreach (var pair in _handlers)
{
var elapsed = DateTime.UtcNow - pair.Value.Item2;
if (elapsed > _ackThreshold)
{
pair.Value.Item1.TrySetException(new TimeoutException("Ack time out"));
}
}
}
}
}

Просмотреть файл

@ -0,0 +1,17 @@
using System.Threading.Tasks;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public class AckInfo
{
public string AckId { get; set; }
public Task<string> AckTask { get; set; }
public AckInfo(string ackId, Task<string> ackTask)
{
AckId = ackId;
AckTask = ackTask;
}
}
}

Просмотреть файл

@ -0,0 +1,11 @@
using System.Threading.Tasks;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public interface IAckHandler
{
AckInfo CreateAck();
void Ack(string id);
}
}

Просмотреть файл

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.2</TargetFramework>
<UserSecretsId>ackablechatroom</UserSecretsId>
<RootNamespace>Microsoft.Azure.SignalR.Samples.AckableChatRoom</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.*" />
<PackageReference Include="Microsoft.Azure.SignalR" Version="1.0.*" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1,40 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public class AckableChatSampleHub : Hub
{
private readonly IAckHandler _ackHandler;
public AckableChatSampleHub(IAckHandler ackHandler)
{
_ackHandler = ackHandler;
}
public void BroadcastMessage(string name, string message)
{
Clients.All.SendAsync("broadcastMessage", name, message);
}
// Complete the task specified by the ackId.
public void Ack(string ackId)
{
_ackHandler.Ack(ackId);
}
// Send the messageContent to the receiver
public async Task<string> SendUserMessage(string messageId, string sender, string receiver, string messageContent)
{
// Create a task and wait for the receiver client to complete it.
var ackInfo = _ackHandler.CreateAck();
await Clients.User(receiver)
.SendAsync("displayUserMessage", messageId, sender, messageContent, ackInfo.AckId);
// Return the task result to the client.
return (await ackInfo.AckTask);
}
}
}

Просмотреть файл

@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
}

Просмотреть файл

@ -0,0 +1,27 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5000/",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ChatRoom": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5000/"
}
}
}

Просмотреть файл

@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
namespace Microsoft.Azure.SignalR.Samples.AckableChatRoom
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddSignalR()
.AddAzureSignalR(options =>
{
// This is a tircky way to associate user name with connection for sample purpose.
// For PROD, we suggest to use authentication and authorization, see here:
// https://docs.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-2.2
options.ClaimsProvider = context => new[]
{
new Claim(ClaimTypes.NameIdentifier, context.Request.Query["username"])
};
});
services.AddSingleton<IAckHandler, AckHandler>();
}
public void Configure(IApplicationBuilder app)
{
app.UseMvc();
app.UseFileServer();
app.UseAzureSignalR(routes =>
{
routes.MapHub<AckableChatSampleHub>("/chat");
}
);
}
}
}

Просмотреть файл

@ -0,0 +1,15 @@
{
"Logging": {
"IncludeScopes": false,
"Debug": {
"LogLevel": {
"Default": "Debug"
}
},
"Console": {
"LogLevel": {
"Default": "Debug"
}
}
}
}

Просмотреть файл

@ -0,0 +1,82 @@
/*html, body {
font-size: 16px;
}
@media all and (max-device-width: 720px) {
html, body {
font-size: 20px;
}
}*/
html, body {
padding: 0;
height: 100%;
}
#messages {
width: 100%;
border: 1px solid #ccc;
height: calc(100% - 200px);
float: none;
margin: 0px auto;
padding-left: 0px;
overflow-y: scroll;
}
textarea:focus {
outline: none !important;
}
.system-message {
background: #87CEFA;
}
.broadcast-message {
display: inline-block;
background: yellow;
margin: auto;
padding: 5px 10px;
}
.message-entry {
overflow: auto;
margin: 8px 0;
}
.message-avatar {
display: inline-block;
padding: 10px;
max-width: 8em;
word-wrap: break-word;
}
.message-content {
display: inline-block;
background-color: #b2e281;
padding: 10px;
margin: 0 0.5em;
max-width: calc(60%);
word-wrap: break-word;
}
.message-content.pull-left:before {
width: 0;
height: 0;
display: inline-block;
float: left;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid #b2e281;
margin: 15px 0;
}
.message-content.pull-right:after {
width: 0;
height: 0;
display: inline-block;
float: right;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 10px solid #b2e281;
margin: 15px 0;
}

Двоичные данные
samples/AckableChatRoom/wwwroot/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 31 KiB

Просмотреть файл

@ -0,0 +1,282 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta name="viewport" content="width=device-width">
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="css/site.css" rel="stylesheet" />
<title>Azure SignalR Group Chat With Ack</title>
</head>
<body>
<h2 class="text-center" style="margin-top: 0; padding-top: 30px; padding-bottom: 30px;">
Azure SignalR Single Chat
</h2>
<div class="container" style="height: calc(100% - 110px);">
<div id="messages" style="background-color: whitesmoke; "></div>
<div style="width: 100%; border-left-style: ridge; border-right-style: ridge;">
<textarea id="message" style="width: 100%; padding: 5px 10px; border-style: hidden;"
placeholder="Type message and press Enter to send..."></textarea>
</div>
<div style="overflow: auto; border-style: ridge; border-top-style: hidden;">
<button class="btn-success pull-right" id="sendmessage">Send All</button>
</div>
<div>
<textarea id="targetName" style="width: 100%; padding: 5px 10px; border-style: hidden;"
placeholder="Type targetName"></textarea>
<button class="btn-success pull-right" id="sendUserMessage">Send To User</button>
</div>
</div>
<div class="modal alert alert-danger fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<div>Connection Error...</div>
<div><strong style="font-size: 1.5em;">Hit Refresh/F5</strong> to rejoin. ;)</div>
</div>
</div>
</div>
</div>
<!--Reference the SignalR library. -->
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/@aspnet/signalr@1.1.0/dist/browser/signalr.min.js">
</script>
<!--Add script to update the page and send messages.-->
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
let unreadMessage = new Map();
// Get the user name and store it to prepend to messages.
let username = Math.random().toString(36).substring(2, 10);
let promptMessage = 'Enter your name:';
do {
username = prompt(promptMessage, username);
if (!username || username.startsWith('_') || username.indexOf('<') > -1 || username.indexOf(
'>') > -1) {
username = '';
promptMessage = 'Invalid input. Enter your name:';
}
} while (!username);
// Genereate a guid for each message
const generateGuid = function () {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0,
v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Html encode message.
const encodedMessage = function (message) {
return message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const createNewMessage = function (sender, message, messageId) {
const entry = document.createElement('div');
entry.classList.add("message-entry");
if (sender === "_SYSTEM_") {
entry.innerHTML = message;
entry.classList.add("text-center");
entry.classList.add("system-message");
} else if (sender === "_BROADCAST_") {
entry.classList.add("text-center");
entry.innerHTML =
`<div class="text-center broadcast-message" id="${messageId}">${message}</div>`;
} else if (sender === username) {
entry.innerHTML =
`<div class="message-avatar pull-right" id="${messageId}">${sender}</div>` +
`<div class="message-content pull-right">${message}<div>`;
} else {
entry.innerHTML =
`<div class="message-avatar pull-left" id="${messageId}">${sender}</div>` +
`<div class="message-content pull-left">${message}<div>`;
}
return entry;
}
const addNewMessageToScreen = function (messageEntry) {
const messageBoxElement = document.getElementById('messages');
messageBoxElement.appendChild(messageEntry);
}
// Set initial focus to message input box.
const messageInput = document.getElementById('message');
const targetNameInput = document.getElementById('targetName');
messageInput.focus();
const sendUserResponse = function (connection) {
unreadMessage.forEach(async (sender, messageId) => {
const messageBoxElement = document.getElementById('messages');
const minViewHeight = messageBoxElement.offsetTop + messageBoxElement.scrollTop;
const maxViewHeight = minViewHeight + messageBoxElement.clientHeight;
const messageElementHeight = document.getElementById(messageId).offsetTop;
// Check if the message is visible in the scroll view.
if (messageElementHeight <= maxViewHeight && messageElementHeight >= minViewHeight) {
unreadMessage.delete(messageId);
await connection.invoke('sendUserMessage', messageId, sender, sender, "Read");
}
});
}
const sendUserMessage = async function (event, connection) {
if (!messageInput.value) {
return;
}
const guid = generateGuid();
// Create the message in the window.
var now = new Date();
const receiver = targetNameInput.value;
const messageText = messageInput.value;
// Clear text box and reset focus for next comment.
messageInput.value = '';
targetNameInput.value = '';
messageInput.focus();
event.preventDefault();
// Create the message in the room.
const messageEntry = createNewMessage(username, messageText, guid);
const messageBoxElement = document.getElementById('messages');
messageBoxElement.appendChild(messageEntry);
// Create and add the message status to the message.
const messageElement = document.getElementById(guid);
const messageStatusEntry = document.createElement('div');
messageStatusEntry.innerHTML =
`<div class="message-avatar pull-right" id="${guid}-Status"> Sending ${now.toLocaleTimeString()}</div>`;
messageElement.appendChild(messageStatusEntry);
console.log("messageId: " + guid + "\nStatus: Sent\nLocal Time: " + now
.toLocaleTimeString());
messageBoxElement.scrollTop = messageBoxElement.scrollHeight;
// Call the sendUserMessage method on the hub.
const result = await connection.invoke('sendUserMessage', guid, username, receiver, messageText);
now = new Date();
const statusElement = document.getElementById(guid + "-Status");
statusElement.innerText = result + " at " + now.toLocaleTimeString();
console.log("messageId: " + guid + "\nStatus: " + result + "\nLocal Time: " + now
.toLocaleTimeString());
}
const bindConnectionMessage = function (connection) {
// Add the message to the screen
const displayPublicMessage = function (sender, message) {
const messageEntry = createNewMessage(sender, encodedMessage(message),
generateGuid());
addNewMessageToScreen(messageEntry);
if (sender === username) {
const messageBoxElement = document.getElementById('messages');
messageBoxElement.scrollTop = messageBoxElement.scrollHeight;
}
};
// Change the status text under the message
const displayAckMessage = function (messageId, messageStatus, ackId) {
const now = new Date();
const entry = document.getElementById(messageId + "-Status");
entry.innerText = messageStatus + " at " + now.toLocaleTimeString();
console.log("messageId: " + messageId + "\nStatus: " + messageStatus +
"\nLocal Time: " + now.toLocaleTimeString());
connection.invoke('ack', ackId);
}
const displayUserMessage = function (messageId, sender, message, ackId) {
if (sender === username) {
displayAckMessage(messageId, message, ackId);
return;
}
const messageEntry = createNewMessage(sender, encodedMessage(message), messageId);
addNewMessageToScreen(messageEntry);
if (document.visibilityState == 'hidden') {
unreadMessage.set(messageId, sender);
} else {
sendUserResponse(connection);
}
connection.invoke('ack', ackId);
};
// Create a function that the hub can call to broadcast messages.
connection.on('broadcastMessage', displayPublicMessage);
connection.on('displayUserMessage', displayUserMessage);
connection.onclose(onConnectionError);
}
function onConnected(connection) {
console.log('connection started');
connection.invoke('broadcastMessage', '_SYSTEM_', username + ' JOINED');
document.getElementById('sendmessage').addEventListener('click', function (event) {
// Call the broadcastMessage method on the hub.
if (messageInput.value) {
connection.invoke('broadcastMessage', username, messageInput.value);
}
// Clear text box and reset focus for next comment.
messageInput.value = '';
messageInput.focus();
event.preventDefault();
});
document.getElementById('sendUserMessage').addEventListener('click', (event) =>
sendUserMessage(event, connection));
document.getElementById('message').addEventListener('keypress', function (event) {
if (event.keyCode === 13) {
event.preventDefault();
document.getElementById('sendmessage').click();
return false;
}
});
document.getElementById('messages').addEventListener('scroll', function (event) {
sendUserResponse(connection);
});
document.addEventListener('visibilitychange', async function () {
if (document.visibilityState === 'visible') {
sendUserResponse(connection);
}
});
}
function onConnectionError(error) {
if (error && error.message) {
console.error(error.message);
}
const modal = document.getElementById('myModal');
modal.classList.add('in');
modal.style = 'display: block;';
}
const connection = new signalR.HubConnectionBuilder()
.withUrl(`/chat?username=${username}`)
.build();
bindConnectionMessage(connection);
connection.start()
.then(function () {
onConnected(connection);
})
.catch(function (error) {
console.error(error.message);
});
});
</script>
</body>
</html>