From 42d502f812a509172b0c231b5129fb536bafe21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:56:56 +0300 Subject: [PATCH] =?UTF-8?q?Claude=20g=C3=BCncellemesi=20MessengerHub=20g?= =?UTF-8?q?=C3=BCncellemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messenger/MessengerDtos.cs | 26 +- .../Messenger/MessengerAppService.cs | 298 +++++++----------- .../Messenger/MessengerHub.cs | 91 +++--- .../Messenger/MessengerPresenceStore.cs | 155 +++++++++ .../components/template/MessengerWidget.tsx | 105 +++--- ui/src/services/messenger.service.ts | 5 + ui/src/services/messenger.signalr.ts | 75 +++-- 7 files changed, 434 insertions(+), 321 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application/Messenger/MessengerPresenceStore.cs diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs b/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs index e5e4471f..89f75201 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs @@ -71,30 +71,10 @@ public class MessengerMessageDeletedDto public List RecipientIds { get; set; } = new(); } -public class MessengerConversationDto : FullAuditedEntityDto +public class MessengerPresenceDto { - public Guid? TenantId { get; set; } - public string? Title { get; set; } - public List 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 ParticipantIds { get; set; } = new(); -} - -public class MessengerConversationListRequestDto : PagedAndSortedResultRequestDto -{ - public string? Filter { get; set; } + public Guid UserId { get; set; } + public bool IsOnline { get; set; } } public class MessengerGetMessagesInput : PagedAndSortedResultRequestDto diff --git a/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs b/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs index 758fdf4c..a7a050f9 100644 --- a/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs +++ b/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs @@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Volo.Abp; -using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; using Volo.Abp.Identity; @@ -25,10 +24,14 @@ namespace Sozsoft.Platform.Messenger; [Authorize] 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 _userRepository; private readonly IRepository _conversationRepository; private readonly IRepository _messageRepository; - private readonly IRepository _identitySessionRepository; + private readonly IMessengerPresenceStore _presenceStore; private readonly BlobManager _blobManager; private readonly IConfiguration _configuration; private readonly IHubContext _messengerHubContext; @@ -37,7 +40,7 @@ public class MessengerAppService : ApplicationService IRepository userRepository, IRepository conversationRepository, IRepository messageRepository, - IRepository identitySessionRepository, + IMessengerPresenceStore presenceStore, BlobManager blobManager, IConfiguration configuration, IHubContext messengerHubContext) @@ -45,7 +48,7 @@ public class MessengerAppService : ApplicationService _userRepository = userRepository; _conversationRepository = conversationRepository; _messageRepository = messageRepository; - _identitySessionRepository = identitySessionRepository; + _presenceStore = presenceStore; _blobManager = blobManager; _configuration = configuration; _messengerHubContext = messengerHubContext; @@ -53,9 +56,9 @@ public class MessengerAppService : ApplicationService public async Task> GetContactsAsync(string? filter = null) { - var query = await _userRepository.GetQueryableAsync(); - var currentUserId = CurrentUser.Id; + var currentUserId = GetCurrentUserId(); var currentTenantId = CurrentTenant.Id; + var query = await _userRepository.GetQueryableAsync(); query = query.Where(user => user.IsActive && @@ -76,10 +79,12 @@ public class MessengerAppService : ApplicationService query .OrderBy(user => user.Name ?? user.UserName) .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 => { @@ -99,107 +104,12 @@ public class MessengerAppService : ApplicationService }).ToList(); } - public async Task> 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( - totalCount, - conversations.Select(MapConversation).ToList() - ); - } - - public async Task GetConversationAsync(Guid id) - { - var conversation = await _conversationRepository.GetAsync(id); - EnsureConversationParticipant(conversation, GetCurrentUserId()); - - return MapConversation(conversation); - } - - [UnitOfWork] - public async Task 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 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")] public async Task> GetMessagesAsync(MessengerGetMessagesInput input) { var currentUserId = GetCurrentUserId(); var conversation = input.ConversationId.HasValue - ? await _conversationRepository.GetAsync(input.ConversationId.Value) + ? await FindConversationByIdAsync(input.ConversationId.Value) : await FindConversationByParticipantKeyAsync( CreateParticipantKey(await NormalizeAndValidateParticipantIdsAsync(input.ParticipantIds, currentUserId)) ); @@ -212,7 +122,9 @@ public class MessengerAppService : ApplicationService EnsureConversationParticipant(conversation, currentUserId); 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() ? $"{nameof(MessengerConversationMessage.SentAt)} desc" @@ -243,7 +155,10 @@ public class MessengerAppService : ApplicationService 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."); } @@ -254,21 +169,28 @@ public class MessengerAppService : ApplicationService 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) { throw new UserFriendlyException("Sadece online kullanicilara mesaj gonderebilirsiniz."); } + var participantIds = recipientIds.Append(senderId).Distinct().ToList(); + var participantKey = CreateParticipantKey(participantIds); + MessengerConversation? conversation = null; 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); + + 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); if (conversation is null) @@ -284,32 +206,30 @@ public class MessengerAppService : ApplicationService 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()) { TenantId = CurrentTenant.Id, ConversationId = conversation.Id, SenderId = senderId, SenderUserName = CurrentUser.UserName ?? string.Empty, - SenderName = senderName, + SenderName = GetCurrentUserDisplayName(), RecipientIdsJson = JsonSerializer.Serialize(recipientIds), Text = trimmedText, - AttachmentsJson = JsonSerializer.Serialize(input.Attachments), + AttachmentsJson = JsonSerializer.Serialize(attachments), SentAt = Clock.Now }; await _messageRepository.InsertAsync(message, autoSave: false); conversation.LastSenderId = senderId; - conversation.LastMessagePreview = GetMessagePreview(trimmedText, input.Attachments.Count); + conversation.LastMessagePreview = GetMessagePreview(trimmedText, attachments.Count); conversation.LastMessageTime = message.SentAt; conversation.MessageCount += 1; await _conversationRepository.UpdateAsync(conversation, autoSave: false); await CurrentUnitOfWork!.SaveChangesAsync(); var messageDto = MapMessage(message); - await PublishMessageReceivedAsync(messageDto); + await PublishToParticipantsAsync("MessengerMessageReceived", messageDto, messageDto.RecipientIds, messageDto.SenderId); return messageDto; } @@ -326,7 +246,7 @@ public class MessengerAppService : ApplicationService 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."); } @@ -359,7 +279,7 @@ public class MessengerAppService : ApplicationService RecipientIds = recipientIds }; - await PublishMessageDeletedAsync(deletedMessage); + await PublishToParticipantsAsync("MessengerMessageDeleted", deletedMessage, recipientIds, message.SenderId); return deletedMessage; } @@ -367,6 +287,8 @@ public class MessengerAppService : ApplicationService [HttpPost("api/app/messenger/upload-attachment")] public async Task UploadAttachmentAsync([FromForm] MessengerUploadAttachmentInput input) { + GetCurrentUserId(); + if (input.File is null || input.File.ContentLength is null or <= 0) { throw new UserFriendlyException("Dosya seçilmelidir."); @@ -378,23 +300,18 @@ public class MessengerAppService : ApplicationService 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 _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 { FileName = originalFileName, SavedFileName = savedFileName, FileType = input.File.ContentType ?? "application/octet-stream", 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; } + 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}"; + } + + /// + /// Istemciden gelen ek bilgilerine guvenilmez: yalnizca bu tenant'in messenger + /// container'ina gercekten yuklenmis dosyalar kabul edilir ve URL sunucuda uretilir. + /// + private async Task> NormalizeAttachmentsAsync(List attachments) + { + if (attachments.Count == 0) + { + return new List(); + } + + if (attachments.Count > MaxAttachmentCount) + { + throw new UserFriendlyException($"En fazla {MaxAttachmentCount} dosya gonderilebilir."); + } + + var normalized = new List(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> NormalizeAndValidateParticipantIdsAsync(IEnumerable participantIds, Guid currentUserId) { var normalizedIds = participantIds @@ -438,32 +417,22 @@ public class MessengerAppService : ApplicationService user.IsActive); } - private async Task> GetOnlineUserIdsAsync(IEnumerable userIds) + private async Task FindConversationByIdAsync(Guid conversationId) { - var normalizedUserIds = userIds - .Where(id => id != Guid.Empty) - .Distinct() - .ToList(); - - if (normalizedUserIds.Count == 0) - { - return new HashSet(); - } - - 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(); + var query = await _conversationRepository.GetQueryableAsync(); + return await AsyncExecuter.FirstOrDefaultAsync( + query.Where(conversation => + conversation.Id == conversationId && + conversation.TenantId == CurrentTenant.Id)); } private async Task FindConversationByParticipantKeyAsync(string participantKey) { 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 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) { return new MessengerMessageDto @@ -547,25 +496,14 @@ public class MessengerAppService : ApplicationService return attachmentCount > 0 ? $"{attachmentCount} dosya" : string.Empty; } - private async Task PublishMessageReceivedAsync(MessengerMessageDto message) + private Task PublishToParticipantsAsync(string eventName, object payload, IEnumerable recipientIds, Guid senderId) { - var targetGroups = message.RecipientIds - .Append(message.SenderId) + var targetGroups = recipientIds + .Append(senderId) .Distinct() .Select(userId => MessengerHub.UserGroupName(CurrentTenant.Id, userId)) .ToList(); - await _messengerHubContext.Clients.Groups(targetGroups).SendAsync("MessengerMessageReceived", message); - } - - 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); + return _messengerHubContext.Clients.Groups(targetGroups).SendAsync(eventName, payload); } } diff --git a/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs b/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs index 5a6b2b8f..504d794a 100644 --- a/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs +++ b/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs @@ -1,38 +1,43 @@ #nullable enable using System; -using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Users; +using Volo.Abp.Security.Claims; namespace Sozsoft.Platform.Messenger; +/// +/// Messenger icin grup uyeligi ve online durum yonetimi. +/// Mesaj gonderme/silme islemleri 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. +/// [Authorize] public class MessengerHub : Hub { - private readonly ICurrentTenant _currentTenant; - private readonly ICurrentUser _currentUser; - private readonly MessengerAppService _messengerAppService; + private readonly IMessengerPresenceStore _presenceStore; - public MessengerHub( - ICurrentTenant currentTenant, - ICurrentUser currentUser, - MessengerAppService messengerAppService) + public MessengerHub(IMessengerPresenceStore presenceStore) { - _currentTenant = currentTenant; - _currentUser = currentUser; - _messengerAppService = messengerAppService; + _presenceStore = presenceStore; } 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, TenantGroupName()); + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(tenantId, userId.Value)); + 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(); @@ -40,45 +45,53 @@ public class MessengerHub : Hub 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)); - await Groups.RemoveFromGroupAsync(Context.ConnectionId, TenantGroupName()); + if (await _presenceStore.RemoveConnectionAsync(tenantId, userId.Value, Context.ConnectionId)) + { + await PublishPresenceAsync(tenantId, userId.Value, false); + } } await base.OnDisconnectedAsync(exception); } - public async Task SendMessage(MessengerSendMessageDto input) + /// + /// Istemcinin online kaydini tazeler. + /// suresinden daha sik cagrilmalidir. + /// + public async Task Ping() { - try + var (tenantId, userId) = GetConnectionIdentity(); + if (userId.HasValue) { - await _messengerAppService.SendMessageAsync(input); - } - catch (OperationCanceledException) when (Context.ConnectionAborted.IsCancellationRequested) - { - return; + await _presenceStore.RefreshAsync(tenantId, userId.Value, Context.ConnectionId); } } - public async Task DeleteMessage(Guid messageId) + private Task PublishPresenceAsync(Guid? tenantId, Guid userId, bool isOnline) { - try - { - await _messengerAppService.DeleteMessageAsync(messageId); - } - catch (OperationCanceledException) when (Context.ConnectionAborted.IsCancellationRequested) - { - return; - } + return Clients.Group(TenantGroupName(tenantId)).SendAsync( + "MessengerPresenceChanged", + new MessengerPresenceDto { UserId = userId, IsOnline = isOnline }); } - 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) => - $"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"; } diff --git a/api/src/Sozsoft.Platform.Application/Messenger/MessengerPresenceStore.cs b/api/src/Sozsoft.Platform.Application/Messenger/MessengerPresenceStore.cs new file mode 100644 index 00000000..a5cbf4e0 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/Messenger/MessengerPresenceStore.cs @@ -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; + +/// +/// 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. +/// +[IgnoreMultiTenancy] +public class MessengerPresenceCacheItem +{ + public List ConnectionIds { get; set; } = new(); +} + +public interface IMessengerPresenceStore +{ + /// Kullanici ilk kez online oldu ise true. + Task AddConnectionAsync(Guid? tenantId, Guid userId, string connectionId); + + /// Kullanicinin son baglantisi da kapandi ise true. + Task RemoveConnectionAsync(Guid? tenantId, Guid userId, string connectionId); + + Task RefreshAsync(Guid? tenantId, Guid userId, string connectionId); + + Task> GetOnlineUserIdsAsync(Guid? tenantId, IEnumerable userIds); +} + +public class MessengerPresenceStore : IMessengerPresenceStore, ITransientDependency +{ + /// + /// Heartbeat gelmezse kaydin dusecegi sure. Istemci bu surenin altinda Ping gonderir. + /// + public static readonly TimeSpan PresenceTimeout = TimeSpan.FromMinutes(3); + + /// + /// 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. + /// + private static readonly CancellationToken PersistentToken = new CancellationTokenSource().Token; + + private readonly IDistributedCache _cache; + + public MessengerPresenceStore(IDistributedCache cache) + { + _cache = cache; + } + + public async Task 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 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> GetOnlineUserIdsAsync(Guid? tenantId, IEnumerable userIds) + { + var normalizedUserIds = userIds + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (normalizedUserIds.Count == 0) + { + return new HashSet(); + } + + 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 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}"; + } +} diff --git a/ui/src/components/template/MessengerWidget.tsx b/ui/src/components/template/MessengerWidget.tsx index e9c9513d..a5b8b769 100644 --- a/ui/src/components/template/MessengerWidget.tsx +++ b/ui/src/components/template/MessengerWidget.tsx @@ -37,9 +37,13 @@ import useThemeClass from '@/utils/hooks/useThemeClass' import { useLocation } from 'react-router-dom' 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 createParticipantKey = (participantIds: string[]) => + [...new Set(participantIds)].sort().join(',') + const formatFileSize = (size: number) => { if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB` return `${(size / (1024 * 1024)).toFixed(1)} MB` @@ -89,7 +93,12 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { const [showEmoji, setShowEmoji] = useState(false) const [multiSelect, setMultiSelect] = useState(false) const [unreadByContact, setUnreadByContact] = useState>({}) - const [reachableContactIds, setReachableContactIds] = useState([]) + + // 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( () => Object.values(unreadByContact).reduce((total, count) => total + count, 0), @@ -99,19 +108,20 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { useEffect(() => { if (!auth.session.signedIn) return + const currentUserId = auth.user.id + const unsubscribeMessage = messengerSignalR.onMessage((message) => { setMessages((prev) => mergeMessages(prev, [message])) - if (message.senderId !== auth.user.id) { - setReachableContactIds((prev) => - prev.includes(message.senderId) ? prev : [...prev, message.senderId], - ) - setContacts((prev) => - prev.map((contact) => - contact.id === message.senderId ? { ...contact, isOnline: true } : contact, - ), - ) - } - if (message.senderId !== auth.user.id && (!open || !selectedIds.includes(message.senderId))) { + + if (message.senderId === currentUserId) return + + setContacts((prev) => + prev.map((contact) => + contact.id === message.senderId ? { ...contact, isOnline: true } : contact, + ), + ) + + if (!openRef.current || !selectedIdsRef.current.includes(message.senderId)) { setUnreadByContact((current) => ({ ...current, [message.senderId]: (current[message.senderId] || 0) + 1, @@ -121,15 +131,23 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { const unsubscribeMessageDeleted = messengerSignalR.onMessageDeleted((message) => { 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) messengerSignalR.start() return () => { unsubscribeMessage() unsubscribeMessageDeleted() + unsubscribePresence() unsubscribeState() } - }, [auth.session.signedIn, auth.user.id, open, selectedIds]) + }, [auth.session.signedIn, auth.user.id]) useEffect(() => { if (!auth.session.signedIn) { @@ -193,40 +211,22 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { const canSendToSelectedContacts = selectedIds.length > 0 && - selectedIds.every((id) => { - const contact = contacts.find((item) => item.id === id) - return contact?.isOnline || reachableContactIds.includes(id) - }) + selectedIds.every((id) => contacts.find((item) => item.id === id)?.isOnline) + // Mesajlar, katilimci kumesi birebir eslesen gorusmeye aittir; aksi halde ikili sohbet + // mesajlari grup sohbetinde de gorunur. const visibleMessages = useMemo(() => { if (selectedIds.length === 0) return [] - return messages.filter((message) => { - const isOwn = message.senderId === auth.user.id - const participants = new Set([message.senderId, ...message.recipientIds]) - return ( - selectedIds.some((id) => participants.has(id)) && - (isOwn || selectedIds.includes(message.senderId)) - ) - }) + const conversationKey = createParticipantKey([...selectedIds, auth.user.id]) + + return messages.filter( + (message) => + createParticipantKey([message.senderId, ...message.recipientIds]) === conversationKey, + ) }, [auth.user.id, messages, selectedIds]) const toggleContact = (id: string) => { - const contact = contacts.find((item) => item.id === id) - const canReachContact = contact?.isOnline || reachableContactIds.includes(id) - if (!canReachContact) { - toast.push( - , - { - placement: 'bottom-end', - }, - ) - return - } - setUnreadByContact((current) => { if (!current[id]) return current @@ -447,8 +447,8 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { onClick={toggleMultiSelect} title={ multiSelect - ? translate('::MessengerWidget.MultiSelect') - : translate('::MessengerWidget.MultiSelectEnabled') + ? translate('::MessengerWidget.MultiSelectEnabled') + : translate('::MessengerWidget.MultiSelect') } /> @@ -457,22 +457,18 @@ const MessengerWidgetContent = ({ className }: { className?: string }) => { {contacts.map((contact) => { const active = selectedIds.includes(contact.id) const contactUnread = unreadByContact[contact.id] || 0 - const isReachable = contact.isOnline || reachableContactIds.includes(contact.id) return (