Claude güncellemesi MessengerHub güncellemesi
This commit is contained in:
parent
bbfa1cee0d
commit
42d502f812
7 changed files with 434 additions and 321 deletions
|
|
@ -71,30 +71,10 @@ public class MessengerMessageDeletedDto
|
||||||
public List<Guid> RecipientIds { get; set; } = new();
|
public List<Guid> RecipientIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessengerConversationDto : FullAuditedEntityDto<Guid>
|
public class MessengerPresenceDto
|
||||||
{
|
{
|
||||||
public Guid? TenantId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public string? Title { get; set; }
|
public bool IsOnline { get; set; }
|
||||||
public List<Guid> ParticipantIds { get; set; } = new();
|
|
||||||
public bool IsGroup { get; set; }
|
|
||||||
public Guid? LastSenderId { get; set; }
|
|
||||||
public string? LastMessagePreview { get; set; }
|
|
||||||
public DateTime? LastMessageTime { get; set; }
|
|
||||||
public int MessageCount { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MessengerConversationCreateUpdateDto
|
|
||||||
{
|
|
||||||
[StringLength(256)]
|
|
||||||
public string? Title { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public List<Guid> ParticipantIds { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MessengerConversationListRequestDto : PagedAndSortedResultRequestDto
|
|
||||||
{
|
|
||||||
public string? Filter { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessengerGetMessagesInput : PagedAndSortedResultRequestDto
|
public class MessengerGetMessagesInput : PagedAndSortedResultRequestDto
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
using Volo.Abp.Application.Dtos;
|
|
||||||
using Volo.Abp.Application.Services;
|
using Volo.Abp.Application.Services;
|
||||||
using Volo.Abp.Domain.Repositories;
|
using Volo.Abp.Domain.Repositories;
|
||||||
using Volo.Abp.Identity;
|
using Volo.Abp.Identity;
|
||||||
|
|
@ -25,10 +24,14 @@ namespace Sozsoft.Platform.Messenger;
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class MessengerAppService : ApplicationService
|
public class MessengerAppService : ApplicationService
|
||||||
{
|
{
|
||||||
|
private const int MaxAttachmentCount = 10;
|
||||||
|
private const int MaxContactCount = 100;
|
||||||
|
private static readonly TimeSpan MessageDeleteWindow = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
private readonly IRepository<IdentityUser, Guid> _userRepository;
|
private readonly IRepository<IdentityUser, Guid> _userRepository;
|
||||||
private readonly IRepository<MessengerConversation, Guid> _conversationRepository;
|
private readonly IRepository<MessengerConversation, Guid> _conversationRepository;
|
||||||
private readonly IRepository<MessengerConversationMessage, Guid> _messageRepository;
|
private readonly IRepository<MessengerConversationMessage, Guid> _messageRepository;
|
||||||
private readonly IRepository<IdentitySession, Guid> _identitySessionRepository;
|
private readonly IMessengerPresenceStore _presenceStore;
|
||||||
private readonly BlobManager _blobManager;
|
private readonly BlobManager _blobManager;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IHubContext<MessengerHub> _messengerHubContext;
|
private readonly IHubContext<MessengerHub> _messengerHubContext;
|
||||||
|
|
@ -37,7 +40,7 @@ public class MessengerAppService : ApplicationService
|
||||||
IRepository<IdentityUser, Guid> userRepository,
|
IRepository<IdentityUser, Guid> userRepository,
|
||||||
IRepository<MessengerConversation, Guid> conversationRepository,
|
IRepository<MessengerConversation, Guid> conversationRepository,
|
||||||
IRepository<MessengerConversationMessage, Guid> messageRepository,
|
IRepository<MessengerConversationMessage, Guid> messageRepository,
|
||||||
IRepository<IdentitySession, Guid> identitySessionRepository,
|
IMessengerPresenceStore presenceStore,
|
||||||
BlobManager blobManager,
|
BlobManager blobManager,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHubContext<MessengerHub> messengerHubContext)
|
IHubContext<MessengerHub> messengerHubContext)
|
||||||
|
|
@ -45,7 +48,7 @@ public class MessengerAppService : ApplicationService
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
_conversationRepository = conversationRepository;
|
_conversationRepository = conversationRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_identitySessionRepository = identitySessionRepository;
|
_presenceStore = presenceStore;
|
||||||
_blobManager = blobManager;
|
_blobManager = blobManager;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_messengerHubContext = messengerHubContext;
|
_messengerHubContext = messengerHubContext;
|
||||||
|
|
@ -53,9 +56,9 @@ public class MessengerAppService : ApplicationService
|
||||||
|
|
||||||
public async Task<List<MessengerContactDto>> GetContactsAsync(string? filter = null)
|
public async Task<List<MessengerContactDto>> GetContactsAsync(string? filter = null)
|
||||||
{
|
{
|
||||||
var query = await _userRepository.GetQueryableAsync();
|
var currentUserId = GetCurrentUserId();
|
||||||
var currentUserId = CurrentUser.Id;
|
|
||||||
var currentTenantId = CurrentTenant.Id;
|
var currentTenantId = CurrentTenant.Id;
|
||||||
|
var query = await _userRepository.GetQueryableAsync();
|
||||||
|
|
||||||
query = query.Where(user =>
|
query = query.Where(user =>
|
||||||
user.IsActive &&
|
user.IsActive &&
|
||||||
|
|
@ -76,10 +79,12 @@ public class MessengerAppService : ApplicationService
|
||||||
query
|
query
|
||||||
.OrderBy(user => user.Name ?? user.UserName)
|
.OrderBy(user => user.Name ?? user.UserName)
|
||||||
.ThenBy(user => user.Surname)
|
.ThenBy(user => user.Surname)
|
||||||
.Take(100)
|
.Take(MaxContactCount)
|
||||||
);
|
);
|
||||||
|
|
||||||
var onlineUserIds = await GetOnlineUserIdsAsync(users.Select(user => user.Id));
|
var onlineUserIds = await _presenceStore.GetOnlineUserIdsAsync(
|
||||||
|
currentTenantId,
|
||||||
|
users.Select(user => user.Id));
|
||||||
|
|
||||||
return users.Select(user =>
|
return users.Select(user =>
|
||||||
{
|
{
|
||||||
|
|
@ -99,107 +104,12 @@ public class MessengerAppService : ApplicationService
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResultDto<MessengerConversationDto>> GetConversationsAsync(MessengerConversationListRequestDto input)
|
|
||||||
{
|
|
||||||
var query = await _conversationRepository.GetQueryableAsync();
|
|
||||||
var currentUserId = GetCurrentUserId();
|
|
||||||
var currentUserKey = currentUserId.ToString("N");
|
|
||||||
|
|
||||||
query = query.Where(conversation => conversation.ParticipantKey.Contains(currentUserKey));
|
|
||||||
|
|
||||||
if (!input.Filter.IsNullOrWhiteSpace())
|
|
||||||
{
|
|
||||||
var filter = input.Filter!.Trim().ToLower();
|
|
||||||
query = query.Where(conversation =>
|
|
||||||
(conversation.Title != null && conversation.Title.ToLower().Contains(filter)) ||
|
|
||||||
(conversation.LastMessagePreview != null && conversation.LastMessagePreview.ToLower().Contains(filter)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalCount = await AsyncExecuter.CountAsync(query);
|
|
||||||
var sorting = input.Sorting.IsNullOrWhiteSpace()
|
|
||||||
? $"{nameof(MessengerConversation.LastMessageTime)} desc"
|
|
||||||
: input.Sorting;
|
|
||||||
|
|
||||||
var conversations = await AsyncExecuter.ToListAsync(
|
|
||||||
query.OrderBy(sorting).Skip(input.SkipCount).Take(input.MaxResultCount)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new PagedResultDto<MessengerConversationDto>(
|
|
||||||
totalCount,
|
|
||||||
conversations.Select(MapConversation).ToList()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MessengerConversationDto> GetConversationAsync(Guid id)
|
|
||||||
{
|
|
||||||
var conversation = await _conversationRepository.GetAsync(id);
|
|
||||||
EnsureConversationParticipant(conversation, GetCurrentUserId());
|
|
||||||
|
|
||||||
return MapConversation(conversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnitOfWork]
|
|
||||||
public async Task<MessengerConversationDto> CreateConversationAsync(MessengerConversationCreateUpdateDto input)
|
|
||||||
{
|
|
||||||
var currentUserId = GetCurrentUserId();
|
|
||||||
var participantIds = await NormalizeAndValidateParticipantIdsAsync(input.ParticipantIds, currentUserId);
|
|
||||||
var participantKey = CreateParticipantKey(participantIds);
|
|
||||||
|
|
||||||
var existing = await FindConversationByParticipantKeyAsync(participantKey);
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
|
||||||
return MapConversation(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
var conversation = new MessengerConversation(GuidGenerator.Create())
|
|
||||||
{
|
|
||||||
TenantId = CurrentTenant.Id,
|
|
||||||
Title = input.Title?.Trim(),
|
|
||||||
ParticipantKey = participantKey,
|
|
||||||
ParticipantIdsJson = JsonSerializer.Serialize(participantIds),
|
|
||||||
IsGroup = participantIds.Count > 2
|
|
||||||
};
|
|
||||||
|
|
||||||
await _conversationRepository.InsertAsync(conversation, autoSave: true);
|
|
||||||
|
|
||||||
return MapConversation(conversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnitOfWork]
|
|
||||||
public async Task<MessengerConversationDto> UpdateConversationAsync(Guid id, MessengerConversationCreateUpdateDto input)
|
|
||||||
{
|
|
||||||
var conversation = await _conversationRepository.GetAsync(id);
|
|
||||||
var currentUserId = GetCurrentUserId();
|
|
||||||
EnsureConversationParticipant(conversation, currentUserId);
|
|
||||||
|
|
||||||
var participantIds = await NormalizeAndValidateParticipantIdsAsync(input.ParticipantIds, currentUserId);
|
|
||||||
var participantKey = CreateParticipantKey(participantIds);
|
|
||||||
|
|
||||||
conversation.Title = input.Title?.Trim();
|
|
||||||
conversation.ParticipantKey = participantKey;
|
|
||||||
conversation.ParticipantIdsJson = JsonSerializer.Serialize(participantIds);
|
|
||||||
conversation.IsGroup = participantIds.Count > 2;
|
|
||||||
|
|
||||||
await _conversationRepository.UpdateAsync(conversation, autoSave: true);
|
|
||||||
|
|
||||||
return MapConversation(conversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
[UnitOfWork]
|
|
||||||
public async Task DeleteConversationAsync(Guid id)
|
|
||||||
{
|
|
||||||
var conversation = await _conversationRepository.GetAsync(id);
|
|
||||||
EnsureConversationParticipant(conversation, GetCurrentUserId());
|
|
||||||
|
|
||||||
await _conversationRepository.DeleteAsync(conversation, autoSave: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("api/app/messenger/messages")]
|
[HttpPost("api/app/messenger/messages")]
|
||||||
public async Task<List<MessengerMessageDto>> GetMessagesAsync(MessengerGetMessagesInput input)
|
public async Task<List<MessengerMessageDto>> GetMessagesAsync(MessengerGetMessagesInput input)
|
||||||
{
|
{
|
||||||
var currentUserId = GetCurrentUserId();
|
var currentUserId = GetCurrentUserId();
|
||||||
var conversation = input.ConversationId.HasValue
|
var conversation = input.ConversationId.HasValue
|
||||||
? await _conversationRepository.GetAsync(input.ConversationId.Value)
|
? await FindConversationByIdAsync(input.ConversationId.Value)
|
||||||
: await FindConversationByParticipantKeyAsync(
|
: await FindConversationByParticipantKeyAsync(
|
||||||
CreateParticipantKey(await NormalizeAndValidateParticipantIdsAsync(input.ParticipantIds, currentUserId))
|
CreateParticipantKey(await NormalizeAndValidateParticipantIdsAsync(input.ParticipantIds, currentUserId))
|
||||||
);
|
);
|
||||||
|
|
@ -212,7 +122,9 @@ public class MessengerAppService : ApplicationService
|
||||||
EnsureConversationParticipant(conversation, currentUserId);
|
EnsureConversationParticipant(conversation, currentUserId);
|
||||||
|
|
||||||
var query = await _messageRepository.GetQueryableAsync();
|
var query = await _messageRepository.GetQueryableAsync();
|
||||||
query = query.Where(message => message.ConversationId == conversation.Id);
|
query = query.Where(message =>
|
||||||
|
message.ConversationId == conversation.Id &&
|
||||||
|
message.TenantId == CurrentTenant.Id);
|
||||||
|
|
||||||
var sorting = input.Sorting.IsNullOrWhiteSpace()
|
var sorting = input.Sorting.IsNullOrWhiteSpace()
|
||||||
? $"{nameof(MessengerConversationMessage.SentAt)} desc"
|
? $"{nameof(MessengerConversationMessage.SentAt)} desc"
|
||||||
|
|
@ -243,7 +155,10 @@ public class MessengerAppService : ApplicationService
|
||||||
throw new UserFriendlyException("En az bir alici secilmelidir.");
|
throw new UserFriendlyException("En az bir alici secilmelidir.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.Text.IsNullOrWhiteSpace() && input.Attachments.Count == 0)
|
var attachments = await NormalizeAttachmentsAsync(input.Attachments);
|
||||||
|
var trimmedText = input.Text?.Trim();
|
||||||
|
|
||||||
|
if (trimmedText.IsNullOrWhiteSpace() && attachments.Count == 0)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Mesaj veya dosya gonderilmelidir.");
|
throw new UserFriendlyException("Mesaj veya dosya gonderilmelidir.");
|
||||||
}
|
}
|
||||||
|
|
@ -254,21 +169,28 @@ public class MessengerAppService : ApplicationService
|
||||||
throw new UserFriendlyException("Bu tenant icinde gecersiz alici var.");
|
throw new UserFriendlyException("Bu tenant icinde gecersiz alici var.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var onlineRecipientIds = await GetOnlineUserIdsAsync(recipientIds);
|
var onlineRecipientIds = await _presenceStore.GetOnlineUserIdsAsync(CurrentTenant.Id, recipientIds);
|
||||||
if (onlineRecipientIds.Count != recipientIds.Count)
|
if (onlineRecipientIds.Count != recipientIds.Count)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Sadece online kullanicilara mesaj gonderebilirsiniz.");
|
throw new UserFriendlyException("Sadece online kullanicilara mesaj gonderebilirsiniz.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var participantIds = recipientIds.Append(senderId).Distinct().ToList();
|
||||||
|
var participantKey = CreateParticipantKey(participantIds);
|
||||||
|
|
||||||
MessengerConversation? conversation = null;
|
MessengerConversation? conversation = null;
|
||||||
if (input.ConversationId.HasValue)
|
if (input.ConversationId.HasValue)
|
||||||
{
|
{
|
||||||
conversation = await _conversationRepository.GetAsync(input.ConversationId.Value);
|
conversation = await FindConversationByIdAsync(input.ConversationId.Value)
|
||||||
|
?? throw new UserFriendlyException("Gorusme bulunamadi.");
|
||||||
EnsureConversationParticipant(conversation, senderId);
|
EnsureConversationParticipant(conversation, senderId);
|
||||||
|
|
||||||
|
if (conversation.ParticipantKey != participantKey)
|
||||||
|
{
|
||||||
|
throw new UserFriendlyException("Alicilar bu gorusme ile eslesmiyor.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var participantIds = recipientIds.Append(senderId).Distinct().ToList();
|
|
||||||
var participantKey = CreateParticipantKey(participantIds);
|
|
||||||
conversation ??= await FindConversationByParticipantKeyAsync(participantKey);
|
conversation ??= await FindConversationByParticipantKeyAsync(participantKey);
|
||||||
|
|
||||||
if (conversation is null)
|
if (conversation is null)
|
||||||
|
|
@ -284,32 +206,30 @@ public class MessengerAppService : ApplicationService
|
||||||
await _conversationRepository.InsertAsync(conversation, autoSave: false);
|
await _conversationRepository.InsertAsync(conversation, autoSave: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderName = $"{CurrentUser.Name} {CurrentUser.SurName}".Trim() ?? CurrentUser.UserName ?? "Kullanici";
|
|
||||||
var trimmedText = input.Text?.Trim();
|
|
||||||
var message = new MessengerConversationMessage(GuidGenerator.Create())
|
var message = new MessengerConversationMessage(GuidGenerator.Create())
|
||||||
{
|
{
|
||||||
TenantId = CurrentTenant.Id,
|
TenantId = CurrentTenant.Id,
|
||||||
ConversationId = conversation.Id,
|
ConversationId = conversation.Id,
|
||||||
SenderId = senderId,
|
SenderId = senderId,
|
||||||
SenderUserName = CurrentUser.UserName ?? string.Empty,
|
SenderUserName = CurrentUser.UserName ?? string.Empty,
|
||||||
SenderName = senderName,
|
SenderName = GetCurrentUserDisplayName(),
|
||||||
RecipientIdsJson = JsonSerializer.Serialize(recipientIds),
|
RecipientIdsJson = JsonSerializer.Serialize(recipientIds),
|
||||||
Text = trimmedText,
|
Text = trimmedText,
|
||||||
AttachmentsJson = JsonSerializer.Serialize(input.Attachments),
|
AttachmentsJson = JsonSerializer.Serialize(attachments),
|
||||||
SentAt = Clock.Now
|
SentAt = Clock.Now
|
||||||
};
|
};
|
||||||
|
|
||||||
await _messageRepository.InsertAsync(message, autoSave: false);
|
await _messageRepository.InsertAsync(message, autoSave: false);
|
||||||
|
|
||||||
conversation.LastSenderId = senderId;
|
conversation.LastSenderId = senderId;
|
||||||
conversation.LastMessagePreview = GetMessagePreview(trimmedText, input.Attachments.Count);
|
conversation.LastMessagePreview = GetMessagePreview(trimmedText, attachments.Count);
|
||||||
conversation.LastMessageTime = message.SentAt;
|
conversation.LastMessageTime = message.SentAt;
|
||||||
conversation.MessageCount += 1;
|
conversation.MessageCount += 1;
|
||||||
await _conversationRepository.UpdateAsync(conversation, autoSave: false);
|
await _conversationRepository.UpdateAsync(conversation, autoSave: false);
|
||||||
await CurrentUnitOfWork!.SaveChangesAsync();
|
await CurrentUnitOfWork!.SaveChangesAsync();
|
||||||
|
|
||||||
var messageDto = MapMessage(message);
|
var messageDto = MapMessage(message);
|
||||||
await PublishMessageReceivedAsync(messageDto);
|
await PublishToParticipantsAsync("MessengerMessageReceived", messageDto, messageDto.RecipientIds, messageDto.SenderId);
|
||||||
|
|
||||||
return messageDto;
|
return messageDto;
|
||||||
}
|
}
|
||||||
|
|
@ -326,7 +246,7 @@ public class MessengerAppService : ApplicationService
|
||||||
throw new UserFriendlyException("Sadece kendi mesajlarinizi silebilirsiniz.");
|
throw new UserFriendlyException("Sadece kendi mesajlarinizi silebilirsiniz.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Clock.Now - message.SentAt > TimeSpan.FromMinutes(10))
|
if (Clock.Now - message.SentAt > MessageDeleteWindow)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Mesajlar ilk 10 dakika icinde silinebilir.");
|
throw new UserFriendlyException("Mesajlar ilk 10 dakika icinde silinebilir.");
|
||||||
}
|
}
|
||||||
|
|
@ -359,7 +279,7 @@ public class MessengerAppService : ApplicationService
|
||||||
RecipientIds = recipientIds
|
RecipientIds = recipientIds
|
||||||
};
|
};
|
||||||
|
|
||||||
await PublishMessageDeletedAsync(deletedMessage);
|
await PublishToParticipantsAsync("MessengerMessageDeleted", deletedMessage, recipientIds, message.SenderId);
|
||||||
|
|
||||||
return deletedMessage;
|
return deletedMessage;
|
||||||
}
|
}
|
||||||
|
|
@ -367,6 +287,8 @@ public class MessengerAppService : ApplicationService
|
||||||
[HttpPost("api/app/messenger/upload-attachment")]
|
[HttpPost("api/app/messenger/upload-attachment")]
|
||||||
public async Task<MessengerAttachmentDto> UploadAttachmentAsync([FromForm] MessengerUploadAttachmentInput input)
|
public async Task<MessengerAttachmentDto> UploadAttachmentAsync([FromForm] MessengerUploadAttachmentInput input)
|
||||||
{
|
{
|
||||||
|
GetCurrentUserId();
|
||||||
|
|
||||||
if (input.File is null || input.File.ContentLength is null or <= 0)
|
if (input.File is null || input.File.ContentLength is null or <= 0)
|
||||||
{
|
{
|
||||||
throw new UserFriendlyException("Dosya seçilmelidir.");
|
throw new UserFriendlyException("Dosya seçilmelidir.");
|
||||||
|
|
@ -378,23 +300,18 @@ public class MessengerAppService : ApplicationService
|
||||||
throw new UserFriendlyException("Dosya adı geçersiz.");
|
throw new UserFriendlyException("Dosya adı geçersiz.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var savedFileName = $"{Guid.NewGuid():N}_{originalFileName}";
|
var savedFileName = $"{GuidGenerator.Create():N}_{originalFileName}";
|
||||||
|
|
||||||
await using var stream = input.File.GetStream();
|
await using var stream = input.File.GetStream();
|
||||||
await _blobManager.SaveAsync(BlobContainerNames.Messenger, savedFileName, stream, true);
|
await _blobManager.SaveAsync(BlobContainerNames.Messenger, savedFileName, stream, true);
|
||||||
|
|
||||||
var tenantPart = CurrentTenant.Id.HasValue
|
|
||||||
? $"tenants/{CurrentTenant.Id.Value}"
|
|
||||||
: "host";
|
|
||||||
var baseUrl = _configuration["App:CdnUrl"]?.TrimEnd('/') ?? string.Empty;
|
|
||||||
|
|
||||||
return new MessengerAttachmentDto
|
return new MessengerAttachmentDto
|
||||||
{
|
{
|
||||||
FileName = originalFileName,
|
FileName = originalFileName,
|
||||||
SavedFileName = savedFileName,
|
SavedFileName = savedFileName,
|
||||||
FileType = input.File.ContentType ?? "application/octet-stream",
|
FileType = input.File.ContentType ?? "application/octet-stream",
|
||||||
FileSize = input.File.ContentLength ?? 0,
|
FileSize = input.File.ContentLength ?? 0,
|
||||||
Url = $"{baseUrl}/{tenantPart}/{BlobContainerNames.Messenger}/{savedFileName}"
|
Url = BuildAttachmentUrl(savedFileName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,6 +325,68 @@ public class MessengerAppService : ApplicationService
|
||||||
return CurrentUser.Id.Value;
|
return CurrentUser.Id.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetCurrentUserDisplayName()
|
||||||
|
{
|
||||||
|
var fullName = $"{CurrentUser.Name} {CurrentUser.SurName}".Trim();
|
||||||
|
|
||||||
|
return fullName.IsNullOrWhiteSpace()
|
||||||
|
? CurrentUser.UserName ?? "Kullanici"
|
||||||
|
: fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildAttachmentUrl(string savedFileName)
|
||||||
|
{
|
||||||
|
var tenantPart = CurrentTenant.Id.HasValue
|
||||||
|
? $"tenants/{CurrentTenant.Id.Value}"
|
||||||
|
: "host";
|
||||||
|
var baseUrl = _configuration["App:CdnUrl"]?.TrimEnd('/') ?? string.Empty;
|
||||||
|
|
||||||
|
return $"{baseUrl}/{tenantPart}/{BlobContainerNames.Messenger}/{savedFileName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Istemciden gelen ek bilgilerine guvenilmez: yalnizca bu tenant'in messenger
|
||||||
|
/// container'ina gercekten yuklenmis dosyalar kabul edilir ve URL sunucuda uretilir.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<MessengerAttachmentDto>> NormalizeAttachmentsAsync(List<MessengerAttachmentDto> attachments)
|
||||||
|
{
|
||||||
|
if (attachments.Count == 0)
|
||||||
|
{
|
||||||
|
return new List<MessengerAttachmentDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.Count > MaxAttachmentCount)
|
||||||
|
{
|
||||||
|
throw new UserFriendlyException($"En fazla {MaxAttachmentCount} dosya gonderilebilir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = new List<MessengerAttachmentDto>(attachments.Count);
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
|
var savedFileName = attachment.SavedFileName?.Trim();
|
||||||
|
if (savedFileName.IsNullOrWhiteSpace() ||
|
||||||
|
savedFileName != Path.GetFileName(savedFileName) ||
|
||||||
|
!await _blobManager.ExistsAsync(BlobContainerNames.Messenger, savedFileName!))
|
||||||
|
{
|
||||||
|
throw new UserFriendlyException("Gecersiz dosya eki.");
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.Add(new MessengerAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileName(attachment.FileName ?? string.Empty),
|
||||||
|
SavedFileName = savedFileName!,
|
||||||
|
FileType = attachment.FileType.IsNullOrWhiteSpace()
|
||||||
|
? "application/octet-stream"
|
||||||
|
: attachment.FileType,
|
||||||
|
FileSize = Math.Max(0, attachment.FileSize),
|
||||||
|
Url = BuildAttachmentUrl(savedFileName!)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<Guid>> NormalizeAndValidateParticipantIdsAsync(IEnumerable<Guid> participantIds, Guid currentUserId)
|
private async Task<List<Guid>> NormalizeAndValidateParticipantIdsAsync(IEnumerable<Guid> participantIds, Guid currentUserId)
|
||||||
{
|
{
|
||||||
var normalizedIds = participantIds
|
var normalizedIds = participantIds
|
||||||
|
|
@ -438,32 +417,22 @@ public class MessengerAppService : ApplicationService
|
||||||
user.IsActive);
|
user.IsActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<HashSet<Guid>> GetOnlineUserIdsAsync(IEnumerable<Guid> userIds)
|
private async Task<MessengerConversation?> FindConversationByIdAsync(Guid conversationId)
|
||||||
{
|
{
|
||||||
var normalizedUserIds = userIds
|
var query = await _conversationRepository.GetQueryableAsync();
|
||||||
.Where(id => id != Guid.Empty)
|
return await AsyncExecuter.FirstOrDefaultAsync(
|
||||||
.Distinct()
|
query.Where(conversation =>
|
||||||
.ToList();
|
conversation.Id == conversationId &&
|
||||||
|
conversation.TenantId == CurrentTenant.Id));
|
||||||
if (normalizedUserIds.Count == 0)
|
|
||||||
{
|
|
||||||
return new HashSet<Guid>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionsQuery = await _identitySessionRepository.GetQueryableAsync();
|
|
||||||
var onlineUserIds = await AsyncExecuter.ToListAsync(
|
|
||||||
sessionsQuery
|
|
||||||
.Where(session => normalizedUserIds.Contains(session.UserId))
|
|
||||||
.Select(session => session.UserId)
|
|
||||||
.Distinct());
|
|
||||||
|
|
||||||
return onlineUserIds.ToHashSet();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MessengerConversation?> FindConversationByParticipantKeyAsync(string participantKey)
|
private async Task<MessengerConversation?> FindConversationByParticipantKeyAsync(string participantKey)
|
||||||
{
|
{
|
||||||
var query = await _conversationRepository.GetQueryableAsync();
|
var query = await _conversationRepository.GetQueryableAsync();
|
||||||
return await AsyncExecuter.FirstOrDefaultAsync(query.Where(conversation => conversation.ParticipantKey == participantKey));
|
return await AsyncExecuter.FirstOrDefaultAsync(
|
||||||
|
query.Where(conversation =>
|
||||||
|
conversation.ParticipantKey == participantKey &&
|
||||||
|
conversation.TenantId == CurrentTenant.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateParticipantKey(IEnumerable<Guid> participantIds)
|
private static string CreateParticipantKey(IEnumerable<Guid> participantIds)
|
||||||
|
|
@ -480,26 +449,6 @@ public class MessengerAppService : ApplicationService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessengerConversationDto MapConversation(MessengerConversation conversation)
|
|
||||||
{
|
|
||||||
return new MessengerConversationDto
|
|
||||||
{
|
|
||||||
Id = conversation.Id,
|
|
||||||
TenantId = conversation.TenantId,
|
|
||||||
Title = conversation.Title,
|
|
||||||
ParticipantIds = DeserializeGuidList(conversation.ParticipantIdsJson),
|
|
||||||
IsGroup = conversation.IsGroup,
|
|
||||||
LastSenderId = conversation.LastSenderId,
|
|
||||||
LastMessagePreview = conversation.LastMessagePreview,
|
|
||||||
LastMessageTime = conversation.LastMessageTime,
|
|
||||||
MessageCount = conversation.MessageCount,
|
|
||||||
CreationTime = conversation.CreationTime,
|
|
||||||
CreatorId = conversation.CreatorId,
|
|
||||||
LastModificationTime = conversation.LastModificationTime,
|
|
||||||
LastModifierId = conversation.LastModifierId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MessengerMessageDto MapMessage(MessengerConversationMessage message)
|
private static MessengerMessageDto MapMessage(MessengerConversationMessage message)
|
||||||
{
|
{
|
||||||
return new MessengerMessageDto
|
return new MessengerMessageDto
|
||||||
|
|
@ -547,25 +496,14 @@ public class MessengerAppService : ApplicationService
|
||||||
return attachmentCount > 0 ? $"{attachmentCount} dosya" : string.Empty;
|
return attachmentCount > 0 ? $"{attachmentCount} dosya" : string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PublishMessageReceivedAsync(MessengerMessageDto message)
|
private Task PublishToParticipantsAsync(string eventName, object payload, IEnumerable<Guid> recipientIds, Guid senderId)
|
||||||
{
|
{
|
||||||
var targetGroups = message.RecipientIds
|
var targetGroups = recipientIds
|
||||||
.Append(message.SenderId)
|
.Append(senderId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Select(userId => MessengerHub.UserGroupName(CurrentTenant.Id, userId))
|
.Select(userId => MessengerHub.UserGroupName(CurrentTenant.Id, userId))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await _messengerHubContext.Clients.Groups(targetGroups).SendAsync("MessengerMessageReceived", message);
|
return _messengerHubContext.Clients.Groups(targetGroups).SendAsync(eventName, payload);
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PublishMessageDeletedAsync(MessengerMessageDeletedDto message)
|
|
||||||
{
|
|
||||||
var targetGroups = message.RecipientIds
|
|
||||||
.Append(message.SenderId)
|
|
||||||
.Distinct()
|
|
||||||
.Select(userId => MessengerHub.UserGroupName(CurrentTenant.Id, userId))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
await _messengerHubContext.Clients.Groups(targetGroups).SendAsync("MessengerMessageDeleted", message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,43 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Security.Claims;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Volo.Abp.MultiTenancy;
|
using Volo.Abp.Security.Claims;
|
||||||
using Volo.Abp.Users;
|
|
||||||
|
|
||||||
namespace Sozsoft.Platform.Messenger;
|
namespace Sozsoft.Platform.Messenger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Messenger icin grup uyeligi ve online durum yonetimi.
|
||||||
|
/// Mesaj gonderme/silme islemleri <see cref="MessengerAppService"/> uzerinden REST ile yapilir.
|
||||||
|
/// Tenant ve kullanici bilgisi ICurrentTenant/ICurrentUser yerine baglantinin claim'lerinden
|
||||||
|
/// okunur: hub metod cagrilari HTTP pipeline'i disinda calistigi icin AsyncLocal tabanli
|
||||||
|
/// context her zaman dolu olmayabilir.
|
||||||
|
/// </summary>
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class MessengerHub : Hub
|
public class MessengerHub : Hub
|
||||||
{
|
{
|
||||||
private readonly ICurrentTenant _currentTenant;
|
private readonly IMessengerPresenceStore _presenceStore;
|
||||||
private readonly ICurrentUser _currentUser;
|
|
||||||
private readonly MessengerAppService _messengerAppService;
|
|
||||||
|
|
||||||
public MessengerHub(
|
public MessengerHub(IMessengerPresenceStore presenceStore)
|
||||||
ICurrentTenant currentTenant,
|
|
||||||
ICurrentUser currentUser,
|
|
||||||
MessengerAppService messengerAppService)
|
|
||||||
{
|
{
|
||||||
_currentTenant = currentTenant;
|
_presenceStore = presenceStore;
|
||||||
_currentUser = currentUser;
|
|
||||||
_messengerAppService = messengerAppService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
if (_currentUser.Id.HasValue)
|
var (tenantId, userId) = GetConnectionIdentity();
|
||||||
|
if (userId.HasValue)
|
||||||
{
|
{
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(_currentUser.Id.Value));
|
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(tenantId, userId.Value));
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, TenantGroupName());
|
await Groups.AddToGroupAsync(Context.ConnectionId, TenantGroupName(tenantId));
|
||||||
|
|
||||||
|
if (await _presenceStore.AddConnectionAsync(tenantId, userId.Value, Context.ConnectionId))
|
||||||
|
{
|
||||||
|
await PublishPresenceAsync(tenantId, userId.Value, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
|
|
@ -40,45 +45,53 @@ public class MessengerHub : Hub
|
||||||
|
|
||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
if (_currentUser.Id.HasValue)
|
var (tenantId, userId) = GetConnectionIdentity();
|
||||||
|
if (userId.HasValue)
|
||||||
{
|
{
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, UserGroupName(_currentUser.Id.Value));
|
if (await _presenceStore.RemoveConnectionAsync(tenantId, userId.Value, Context.ConnectionId))
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, TenantGroupName());
|
{
|
||||||
|
await PublishPresenceAsync(tenantId, userId.Value, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await base.OnDisconnectedAsync(exception);
|
await base.OnDisconnectedAsync(exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessage(MessengerSendMessageDto input)
|
/// <summary>
|
||||||
|
/// Istemcinin online kaydini tazeler. <see cref="MessengerPresenceStore.PresenceTimeout"/>
|
||||||
|
/// suresinden daha sik cagrilmalidir.
|
||||||
|
/// </summary>
|
||||||
|
public async Task Ping()
|
||||||
{
|
{
|
||||||
try
|
var (tenantId, userId) = GetConnectionIdentity();
|
||||||
|
if (userId.HasValue)
|
||||||
{
|
{
|
||||||
await _messengerAppService.SendMessageAsync(input);
|
await _presenceStore.RefreshAsync(tenantId, userId.Value, Context.ConnectionId);
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (Context.ConnectionAborted.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteMessage(Guid messageId)
|
private Task PublishPresenceAsync(Guid? tenantId, Guid userId, bool isOnline)
|
||||||
{
|
{
|
||||||
try
|
return Clients.Group(TenantGroupName(tenantId)).SendAsync(
|
||||||
{
|
"MessengerPresenceChanged",
|
||||||
await _messengerAppService.DeleteMessageAsync(messageId);
|
new MessengerPresenceDto { UserId = userId, IsOnline = isOnline });
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (Context.ConnectionAborted.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string TenantKey => _currentTenant.Id?.ToString("N") ?? "host";
|
private (Guid? TenantId, Guid? UserId) GetConnectionIdentity()
|
||||||
|
{
|
||||||
|
var user = Context.User;
|
||||||
|
return (ParseGuid(user?.FindFirst(AbpClaimTypes.TenantId)?.Value),
|
||||||
|
ParseGuid(user?.FindFirst(AbpClaimTypes.UserId)?.Value));
|
||||||
|
}
|
||||||
|
|
||||||
private string TenantGroupName() => $"messenger:tenant:{TenantKey}";
|
private static Guid? ParseGuid(string? value) =>
|
||||||
|
Guid.TryParse(value, out var parsed) ? parsed : null;
|
||||||
|
|
||||||
|
public static string TenantGroupName(Guid? tenantId) =>
|
||||||
|
$"messenger:tenant:{TenantKey(tenantId)}";
|
||||||
|
|
||||||
public static string UserGroupName(Guid? tenantId, Guid userId) =>
|
public static string UserGroupName(Guid? tenantId, Guid userId) =>
|
||||||
$"messenger:user:{tenantId?.ToString("N") ?? "host"}:{userId:N}";
|
$"messenger:user:{TenantKey(tenantId)}:{userId:N}";
|
||||||
|
|
||||||
private string UserGroupName(Guid userId) => UserGroupName(_currentTenant.Id, userId);
|
private static string TenantKey(Guid? tenantId) => tenantId?.ToString("N") ?? "host";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Volo.Abp.Caching;
|
||||||
|
using Volo.Abp.DependencyInjection;
|
||||||
|
using Volo.Abp.MultiTenancy;
|
||||||
|
|
||||||
|
namespace Sozsoft.Platform.Messenger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Messenger presence kaydi. Tenant bilgisi cache anahtarina acikca yazildigi icin
|
||||||
|
/// ABP'nin tenant bazli anahtar normalizasyonu devre disi birakilir; boylece hub
|
||||||
|
/// (tenant context'i olmayabilir) ve app service ayni anahtari gorur.
|
||||||
|
/// </summary>
|
||||||
|
[IgnoreMultiTenancy]
|
||||||
|
public class MessengerPresenceCacheItem
|
||||||
|
{
|
||||||
|
public List<string> ConnectionIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IMessengerPresenceStore
|
||||||
|
{
|
||||||
|
/// <returns>Kullanici ilk kez online oldu ise true.</returns>
|
||||||
|
Task<bool> AddConnectionAsync(Guid? tenantId, Guid userId, string connectionId);
|
||||||
|
|
||||||
|
/// <returns>Kullanicinin son baglantisi da kapandi ise true.</returns>
|
||||||
|
Task<bool> RemoveConnectionAsync(Guid? tenantId, Guid userId, string connectionId);
|
||||||
|
|
||||||
|
Task RefreshAsync(Guid? tenantId, Guid userId, string connectionId);
|
||||||
|
|
||||||
|
Task<HashSet<Guid>> GetOnlineUserIdsAsync(Guid? tenantId, IEnumerable<Guid> userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MessengerPresenceStore : IMessengerPresenceStore, ITransientDependency
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Heartbeat gelmezse kaydin dusecegi sure. Istemci bu surenin altinda Ping gonderir.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly TimeSpan PresenceTimeout = TimeSpan.FromMinutes(3);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Presence islemleri istegin/baglantinin omrune bagli olmamalidir: OnDisconnectedAsync
|
||||||
|
/// icinde ICancellationTokenProvider'in tokeni zaten iptal edilmis olur ve cache cagrisi
|
||||||
|
/// OperationCanceledException atar. CancellationToken.None default degere esit oldugu icin
|
||||||
|
/// ABP onu yine provider tokenina dusurur; bu yuzden hicbir zaman iptal edilmeyen ayri bir
|
||||||
|
/// token kullaniliyor.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly CancellationToken PersistentToken = new CancellationTokenSource().Token;
|
||||||
|
|
||||||
|
private readonly IDistributedCache<MessengerPresenceCacheItem> _cache;
|
||||||
|
|
||||||
|
public MessengerPresenceStore(IDistributedCache<MessengerPresenceCacheItem> cache)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AddConnectionAsync(Guid? tenantId, Guid userId, string connectionId)
|
||||||
|
{
|
||||||
|
var key = CreateKey(tenantId, userId);
|
||||||
|
var item = await GetAsync(key) ?? new MessengerPresenceCacheItem();
|
||||||
|
var wasOffline = item.ConnectionIds.Count == 0;
|
||||||
|
|
||||||
|
if (!item.ConnectionIds.Contains(connectionId))
|
||||||
|
{
|
||||||
|
item.ConnectionIds.Add(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetAsync(key, item);
|
||||||
|
|
||||||
|
return wasOffline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemoveConnectionAsync(Guid? tenantId, Guid userId, string connectionId)
|
||||||
|
{
|
||||||
|
var key = CreateKey(tenantId, userId);
|
||||||
|
var item = await GetAsync(key);
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ConnectionIds.Remove(connectionId);
|
||||||
|
|
||||||
|
if (item.ConnectionIds.Count == 0)
|
||||||
|
{
|
||||||
|
await _cache.RemoveAsync(key, hideErrors: true, token: PersistentToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetAsync(key, item);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync(Guid? tenantId, Guid userId, string connectionId)
|
||||||
|
{
|
||||||
|
var key = CreateKey(tenantId, userId);
|
||||||
|
var item = await GetAsync(key) ?? new MessengerPresenceCacheItem();
|
||||||
|
|
||||||
|
if (!item.ConnectionIds.Contains(connectionId))
|
||||||
|
{
|
||||||
|
item.ConnectionIds.Add(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetAsync(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<Guid>> GetOnlineUserIdsAsync(Guid? tenantId, IEnumerable<Guid> userIds)
|
||||||
|
{
|
||||||
|
var normalizedUserIds = userIds
|
||||||
|
.Where(id => id != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (normalizedUserIds.Count == 0)
|
||||||
|
{
|
||||||
|
return new HashSet<Guid>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = normalizedUserIds.ToDictionary(id => CreateKey(tenantId, id), id => id);
|
||||||
|
var items = await _cache.GetManyAsync(keys.Keys, hideErrors: true, token: PersistentToken);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.Where(item => item.Value is not null && item.Value.ConnectionIds.Count > 0)
|
||||||
|
.Select(item => keys[item.Key])
|
||||||
|
.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<MessengerPresenceCacheItem?> GetAsync(string key)
|
||||||
|
{
|
||||||
|
return _cache.GetAsync(key, hideErrors: true, token: PersistentToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task SetAsync(string key, MessengerPresenceCacheItem item)
|
||||||
|
{
|
||||||
|
return _cache.SetAsync(
|
||||||
|
key,
|
||||||
|
item,
|
||||||
|
new DistributedCacheEntryOptions
|
||||||
|
{
|
||||||
|
AbsoluteExpirationRelativeToNow = PresenceTimeout
|
||||||
|
},
|
||||||
|
hideErrors: true,
|
||||||
|
token: PersistentToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateKey(Guid? tenantId, Guid userId)
|
||||||
|
{
|
||||||
|
return $"{tenantId?.ToString("N") ?? "host"}:{userId:N}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,9 +37,13 @@ import useThemeClass from '@/utils/hooks/useThemeClass'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
||||||
const CONTACT_REFRESH_INTERVAL = 30000
|
// Online durumu hub uzerinden anlik geliyor; periyodik yenileme yalnizca listeyi tazelemek icin.
|
||||||
|
const CONTACT_REFRESH_INTERVAL = 60000
|
||||||
const EmojiPicker = lazy(() => import('emoji-picker-react'))
|
const EmojiPicker = lazy(() => import('emoji-picker-react'))
|
||||||
|
|
||||||
|
const createParticipantKey = (participantIds: string[]) =>
|
||||||
|
[...new Set(participantIds)].sort().join(',')
|
||||||
|
|
||||||
const formatFileSize = (size: number) => {
|
const formatFileSize = (size: number) => {
|
||||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
|
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
|
||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
|
@ -89,7 +93,12 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
const [showEmoji, setShowEmoji] = useState(false)
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
const [multiSelect, setMultiSelect] = useState(false)
|
const [multiSelect, setMultiSelect] = useState(false)
|
||||||
const [unreadByContact, setUnreadByContact] = useState<Record<string, number>>({})
|
const [unreadByContact, setUnreadByContact] = useState<Record<string, number>>({})
|
||||||
const [reachableContactIds, setReachableContactIds] = useState<string[]>([])
|
|
||||||
|
// Hub aboneligi yalnizca oturum degistiginde kurulur; guncel panel durumu ref uzerinden okunur.
|
||||||
|
const openRef = useRef(open)
|
||||||
|
const selectedIdsRef = useRef(selectedIds)
|
||||||
|
openRef.current = open
|
||||||
|
selectedIdsRef.current = selectedIds
|
||||||
|
|
||||||
const unread = useMemo(
|
const unread = useMemo(
|
||||||
() => Object.values(unreadByContact).reduce((total, count) => total + count, 0),
|
() => Object.values(unreadByContact).reduce((total, count) => total + count, 0),
|
||||||
|
|
@ -99,19 +108,20 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.session.signedIn) return
|
if (!auth.session.signedIn) return
|
||||||
|
|
||||||
|
const currentUserId = auth.user.id
|
||||||
|
|
||||||
const unsubscribeMessage = messengerSignalR.onMessage((message) => {
|
const unsubscribeMessage = messengerSignalR.onMessage((message) => {
|
||||||
setMessages((prev) => mergeMessages(prev, [message]))
|
setMessages((prev) => mergeMessages(prev, [message]))
|
||||||
if (message.senderId !== auth.user.id) {
|
|
||||||
setReachableContactIds((prev) =>
|
if (message.senderId === currentUserId) return
|
||||||
prev.includes(message.senderId) ? prev : [...prev, message.senderId],
|
|
||||||
)
|
|
||||||
setContacts((prev) =>
|
setContacts((prev) =>
|
||||||
prev.map((contact) =>
|
prev.map((contact) =>
|
||||||
contact.id === message.senderId ? { ...contact, isOnline: true } : contact,
|
contact.id === message.senderId ? { ...contact, isOnline: true } : contact,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (message.senderId !== auth.user.id && (!open || !selectedIds.includes(message.senderId))) {
|
if (!openRef.current || !selectedIdsRef.current.includes(message.senderId)) {
|
||||||
setUnreadByContact((current) => ({
|
setUnreadByContact((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[message.senderId]: (current[message.senderId] || 0) + 1,
|
[message.senderId]: (current[message.senderId] || 0) + 1,
|
||||||
|
|
@ -121,15 +131,23 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
const unsubscribeMessageDeleted = messengerSignalR.onMessageDeleted((message) => {
|
const unsubscribeMessageDeleted = messengerSignalR.onMessageDeleted((message) => {
|
||||||
setMessages((prev) => prev.filter((item) => item.id !== message.messageId))
|
setMessages((prev) => prev.filter((item) => item.id !== message.messageId))
|
||||||
})
|
})
|
||||||
|
const unsubscribePresence = messengerSignalR.onPresenceChange((presence) => {
|
||||||
|
setContacts((prev) =>
|
||||||
|
prev.map((contact) =>
|
||||||
|
contact.id === presence.userId ? { ...contact, isOnline: presence.isOnline } : contact,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
const unsubscribeState = messengerSignalR.onStateChange(setConnected)
|
const unsubscribeState = messengerSignalR.onStateChange(setConnected)
|
||||||
messengerSignalR.start()
|
messengerSignalR.start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeMessage()
|
unsubscribeMessage()
|
||||||
unsubscribeMessageDeleted()
|
unsubscribeMessageDeleted()
|
||||||
|
unsubscribePresence()
|
||||||
unsubscribeState()
|
unsubscribeState()
|
||||||
}
|
}
|
||||||
}, [auth.session.signedIn, auth.user.id, open, selectedIds])
|
}, [auth.session.signedIn, auth.user.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.session.signedIn) {
|
if (!auth.session.signedIn) {
|
||||||
|
|
@ -193,40 +211,22 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
|
|
||||||
const canSendToSelectedContacts =
|
const canSendToSelectedContacts =
|
||||||
selectedIds.length > 0 &&
|
selectedIds.length > 0 &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id) => contacts.find((item) => item.id === id)?.isOnline)
|
||||||
const contact = contacts.find((item) => item.id === id)
|
|
||||||
return contact?.isOnline || reachableContactIds.includes(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Mesajlar, katilimci kumesi birebir eslesen gorusmeye aittir; aksi halde ikili sohbet
|
||||||
|
// mesajlari grup sohbetinde de gorunur.
|
||||||
const visibleMessages = useMemo(() => {
|
const visibleMessages = useMemo(() => {
|
||||||
if (selectedIds.length === 0) return []
|
if (selectedIds.length === 0) return []
|
||||||
|
|
||||||
return messages.filter((message) => {
|
const conversationKey = createParticipantKey([...selectedIds, auth.user.id])
|
||||||
const isOwn = message.senderId === auth.user.id
|
|
||||||
const participants = new Set([message.senderId, ...message.recipientIds])
|
return messages.filter(
|
||||||
return (
|
(message) =>
|
||||||
selectedIds.some((id) => participants.has(id)) &&
|
createParticipantKey([message.senderId, ...message.recipientIds]) === conversationKey,
|
||||||
(isOwn || selectedIds.includes(message.senderId))
|
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}, [auth.user.id, messages, selectedIds])
|
}, [auth.user.id, messages, selectedIds])
|
||||||
|
|
||||||
const toggleContact = (id: string) => {
|
const toggleContact = (id: string) => {
|
||||||
const contact = contacts.find((item) => item.id === id)
|
|
||||||
const canReachContact = contact?.isOnline || reachableContactIds.includes(id)
|
|
||||||
if (!canReachContact) {
|
|
||||||
toast.push(
|
|
||||||
<Notification
|
|
||||||
title={translate('::MessengerWidget.OfflineUsersCannotReceiveMessages')}
|
|
||||||
type="warning"
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
placement: 'bottom-end',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setUnreadByContact((current) => {
|
setUnreadByContact((current) => {
|
||||||
if (!current[id]) return current
|
if (!current[id]) return current
|
||||||
|
|
||||||
|
|
@ -447,8 +447,8 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
onClick={toggleMultiSelect}
|
onClick={toggleMultiSelect}
|
||||||
title={
|
title={
|
||||||
multiSelect
|
multiSelect
|
||||||
? translate('::MessengerWidget.MultiSelect')
|
? translate('::MessengerWidget.MultiSelectEnabled')
|
||||||
: translate('::MessengerWidget.MultiSelectEnabled')
|
: translate('::MessengerWidget.MultiSelect')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -457,22 +457,18 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
{contacts.map((contact) => {
|
{contacts.map((contact) => {
|
||||||
const active = selectedIds.includes(contact.id)
|
const active = selectedIds.includes(contact.id)
|
||||||
const contactUnread = unreadByContact[contact.id] || 0
|
const contactUnread = unreadByContact[contact.id] || 0
|
||||||
const isReachable = contact.isOnline || reachableContactIds.includes(contact.id)
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={contact.id}
|
key={contact.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleContact(contact.id)}
|
onClick={() => toggleContact(contact.id)}
|
||||||
disabled={!isReachable}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
block
|
block
|
||||||
className={`mb-1 !flex !h-auto items-center gap-3 !rounded-md !px-2 !py-2 text-left transition ${
|
className={`mb-1 !flex !h-auto items-center gap-3 !rounded-md !px-2 !py-2 text-left transition ${
|
||||||
active
|
active
|
||||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100'
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100'
|
||||||
: isReachable
|
: 'hover:bg-white dark:hover:bg-gray-800'
|
||||||
? 'hover:bg-white dark:hover:bg-gray-800'
|
|
||||||
: 'cursor-not-allowed opacity-60'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
@ -486,15 +482,15 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 text-xs font-medium ${
|
className={`inline-flex items-center gap-1 text-xs font-medium ${
|
||||||
isReachable ? 'text-emerald-600' : 'text-red-500'
|
contact.isOnline ? 'text-emerald-600' : 'text-red-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-2 w-2 rounded-full ${
|
className={`h-2 w-2 rounded-full ${
|
||||||
isReachable ? 'bg-emerald-500' : 'bg-red-500'
|
contact.isOnline ? 'bg-emerald-500' : 'bg-red-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isReachable
|
{contact.isOnline
|
||||||
? translate('::MessengerWidget.Online')
|
? translate('::MessengerWidget.Online')
|
||||||
: translate('::MessengerWidget.Offline')}
|
: translate('::MessengerWidget.Offline')}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -747,9 +743,12 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => {
|
||||||
<textarea
|
<textarea
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={translate('::MessengerWidget.WriteMessage')}
|
placeholder={
|
||||||
|
canSendToSelectedContacts
|
||||||
|
? translate('::MessengerWidget.WriteMessage')
|
||||||
|
: translate('::MessengerWidget.OfflineUsersCannotReceiveMessages')
|
||||||
|
}
|
||||||
rows={1}
|
rows={1}
|
||||||
autoFocus
|
|
||||||
disabled={!canSendToSelectedContacts}
|
disabled={!canSendToSelectedContacts}
|
||||||
className="min-h-[36px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-100"
|
className="min-h-[36px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-100"
|
||||||
onChange={(event) => setText(event.target.value)}
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ export interface MessengerMessageDto {
|
||||||
sentAt: string
|
sentAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessengerPresenceDto {
|
||||||
|
userId: string
|
||||||
|
isOnline: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessengerMessageDeletedDto {
|
export interface MessengerMessageDeletedDto {
|
||||||
messageId: string
|
messageId: string
|
||||||
conversationId: string
|
conversationId: string
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,29 @@ import * as signalR from '@microsoft/signalr'
|
||||||
import {
|
import {
|
||||||
MessengerMessageDeletedDto,
|
MessengerMessageDeletedDto,
|
||||||
MessengerMessageDto,
|
MessengerMessageDto,
|
||||||
MessengerSendMessageDto,
|
MessengerPresenceDto,
|
||||||
} from './messenger.service'
|
} from './messenger.service'
|
||||||
|
|
||||||
type MessageHandler = (message: MessengerMessageDto) => void
|
type MessageHandler = (message: MessengerMessageDto) => void
|
||||||
type MessageDeletedHandler = (message: MessengerMessageDeletedDto) => void
|
type MessageDeletedHandler = (message: MessengerMessageDeletedDto) => void
|
||||||
|
type PresenceHandler = (presence: MessengerPresenceDto) => void
|
||||||
type StateHandler = (connected: boolean) => void
|
type StateHandler = (connected: boolean) => void
|
||||||
|
|
||||||
|
// Sunucudaki presence kaydi 3 dakikada dusuyor; altinda kalacak sekilde heartbeat gonderilir.
|
||||||
|
const HEARTBEAT_INTERVAL = 60000
|
||||||
|
|
||||||
class MessengerSignalRService {
|
class MessengerSignalRService {
|
||||||
private connection?: signalR.HubConnection
|
private connection?: signalR.HubConnection
|
||||||
|
private heartbeatTimer?: number
|
||||||
private messageHandlers = new Set<MessageHandler>()
|
private messageHandlers = new Set<MessageHandler>()
|
||||||
private messageDeletedHandlers = new Set<MessageDeletedHandler>()
|
private messageDeletedHandlers = new Set<MessageDeletedHandler>()
|
||||||
|
private presenceHandlers = new Set<PresenceHandler>()
|
||||||
private stateHandlers = new Set<StateHandler>()
|
private stateHandlers = new Set<StateHandler>()
|
||||||
|
|
||||||
private createConnection() {
|
private createConnection() {
|
||||||
const { auth } = store.getState()
|
|
||||||
|
|
||||||
this.connection = new signalR.HubConnectionBuilder()
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
.withUrl(`${import.meta.env.VITE_API_URL}/messengerhub`, {
|
.withUrl(`${import.meta.env.VITE_API_URL}/messengerhub`, {
|
||||||
accessTokenFactory: () => store.getState().auth.session.token || auth.session.token || '',
|
accessTokenFactory: () => store.getState().auth.session.token || '',
|
||||||
})
|
})
|
||||||
.withAutomaticReconnect()
|
.withAutomaticReconnect()
|
||||||
.configureLogging(signalR.LogLevel.Warning)
|
.configureLogging(signalR.LogLevel.Warning)
|
||||||
|
|
@ -35,9 +39,19 @@ class MessengerSignalRService {
|
||||||
this.messageDeletedHandlers.forEach((handler) => handler(message))
|
this.messageDeletedHandlers.forEach((handler) => handler(message))
|
||||||
})
|
})
|
||||||
|
|
||||||
this.connection.onreconnected(() => this.emitState(true))
|
this.connection.on('MessengerPresenceChanged', (presence: MessengerPresenceDto) => {
|
||||||
|
this.presenceHandlers.forEach((handler) => handler(presence))
|
||||||
|
})
|
||||||
|
|
||||||
|
this.connection.onreconnected(() => {
|
||||||
|
this.emitState(true)
|
||||||
|
this.startHeartbeat()
|
||||||
|
})
|
||||||
this.connection.onreconnecting(() => this.emitState(false))
|
this.connection.onreconnecting(() => this.emitState(false))
|
||||||
this.connection.onclose(() => this.emitState(false))
|
this.connection.onclose(() => {
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.emitState(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
|
@ -52,10 +66,11 @@ class MessengerSignalRService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.connection?.state === signalR.HubConnectionState.Connecting) return
|
if (this.connection?.state !== signalR.HubConnectionState.Disconnected) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.connection?.start()
|
await this.connection.start()
|
||||||
|
this.startHeartbeat()
|
||||||
this.emitState(true)
|
this.emitState(true)
|
||||||
} catch {
|
} catch {
|
||||||
this.emitState(false)
|
this.emitState(false)
|
||||||
|
|
@ -65,27 +80,15 @@ class MessengerSignalRService {
|
||||||
async stop() {
|
async stop() {
|
||||||
if (!this.connection) return
|
if (!this.connection) return
|
||||||
|
|
||||||
await this.connection.stop()
|
this.stopHeartbeat()
|
||||||
|
const connection = this.connection
|
||||||
this.connection = undefined
|
this.connection = undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.stop()
|
||||||
|
} finally {
|
||||||
this.emitState(false)
|
this.emitState(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(input: MessengerSendMessageDto) {
|
|
||||||
await this.start()
|
|
||||||
if (this.connection?.state !== signalR.HubConnectionState.Connected) {
|
|
||||||
throw new Error('Messenger bağlantısı yok')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.connection.invoke('SendMessage', input)
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMessage(messageId: string) {
|
|
||||||
await this.start()
|
|
||||||
if (this.connection?.state !== signalR.HubConnectionState.Connected) {
|
|
||||||
throw new Error('Messenger bağlantısı yok')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.connection.invoke('DeleteMessage', messageId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMessage(handler: MessageHandler) {
|
onMessage(handler: MessageHandler) {
|
||||||
|
|
@ -98,6 +101,11 @@ class MessengerSignalRService {
|
||||||
return () => this.messageDeletedHandlers.delete(handler)
|
return () => this.messageDeletedHandlers.delete(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPresenceChange(handler: PresenceHandler) {
|
||||||
|
this.presenceHandlers.add(handler)
|
||||||
|
return () => this.presenceHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
onStateChange(handler: StateHandler) {
|
onStateChange(handler: StateHandler) {
|
||||||
this.stateHandlers.add(handler)
|
this.stateHandlers.add(handler)
|
||||||
handler(this.getConnectionState())
|
handler(this.getConnectionState())
|
||||||
|
|
@ -108,6 +116,21 @@ class MessengerSignalRService {
|
||||||
return this.connection?.state === signalR.HubConnectionState.Connected
|
return this.connection?.state === signalR.HubConnectionState.Connected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.heartbeatTimer = window.setInterval(() => {
|
||||||
|
if (this.connection?.state !== signalR.HubConnectionState.Connected) return
|
||||||
|
this.connection.invoke('Ping').catch(() => undefined)
|
||||||
|
}, HEARTBEAT_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
window.clearInterval(this.heartbeatTimer)
|
||||||
|
this.heartbeatTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private emitState(connected: boolean) {
|
private emitState(connected: boolean) {
|
||||||
this.stateHandlers.forEach((handler) => handler(connected))
|
this.stateHandlers.forEach((handler) => handler(connected))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue