diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs b/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs new file mode 100644 index 0000000..ab1a428 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/Messenger/MessengerDtos.cs @@ -0,0 +1,59 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Volo.Abp.Content; + +namespace Sozsoft.Platform.Messenger; + +public class MessengerContactDto +{ + public Guid Id { get; set; } + public Guid? TenantId { get; set; } + public string UserName { get; set; } = string.Empty; + public string? Name { get; set; } + public string? Surname { get; set; } + public string FullName { get; set; } = string.Empty; + public string? Email { get; set; } + public bool IsActive { get; set; } +} + +public class MessengerAttachmentDto +{ + public string FileName { get; set; } = string.Empty; + public string SavedFileName { get; set; } = string.Empty; + public string FileType { get; set; } = "application/octet-stream"; + public long FileSize { get; set; } + public string Url { get; set; } = string.Empty; +} + +public class MessengerUploadAttachmentInput +{ + [Required] + public IRemoteStreamContent File { get; set; } = default!; +} + +public class MessengerSendMessageDto +{ + [Required] + public List RecipientIds { get; set; } = new(); + + [StringLength(4096)] + public string? Text { get; set; } + + public List Attachments { get; set; } = new(); +} + +public class MessengerMessageDto +{ + public Guid Id { get; set; } + public Guid? TenantId { get; set; } + public Guid SenderId { get; set; } + public string SenderUserName { get; set; } = string.Empty; + public string SenderName { get; set; } = string.Empty; + public List RecipientIds { get; set; } = new(); + public string? Text { get; set; } + public List Attachments { get; set; } = new(); + public DateTime SentAt { get; set; } +} diff --git a/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs b/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs new file mode 100644 index 0000000..8bab43a --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/Messenger/MessengerAppService.cs @@ -0,0 +1,114 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Sozsoft.Platform.BlobStoring; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; + +namespace Sozsoft.Platform.Messenger; + +[Authorize] +public class MessengerAppService : ApplicationService +{ + private readonly IRepository _userRepository; + private readonly BlobManager _blobManager; + private readonly IConfiguration _configuration; + + public MessengerAppService( + IRepository userRepository, + BlobManager blobManager, + IConfiguration configuration) + { + _userRepository = userRepository; + _blobManager = blobManager; + _configuration = configuration; + } + + public async Task> GetContactsAsync(string? filter = null) + { + var query = await _userRepository.GetQueryableAsync(); + var currentUserId = CurrentUser.Id; + var currentTenantId = CurrentTenant.Id; + + query = query.Where(user => + user.IsActive && + user.Id != currentUserId && + user.TenantId == currentTenantId); + + if (!filter.IsNullOrWhiteSpace()) + { + var normalizedFilter = filter!.Trim().ToLower(); + query = query.Where(user => + user.UserName.ToLower().Contains(normalizedFilter) || + (user.Name != null && user.Name.ToLower().Contains(normalizedFilter)) || + (user.Surname != null && user.Surname.ToLower().Contains(normalizedFilter)) || + (user.Email != null && user.Email.ToLower().Contains(normalizedFilter))); + } + + var users = await AsyncExecuter.ToListAsync( + query + .OrderBy(user => user.Name ?? user.UserName) + .ThenBy(user => user.Surname) + .Take(100) + ); + + return users.Select(user => + { + var fullName = $"{user.Name} {user.Surname}".Trim(); + return new MessengerContactDto + { + Id = user.Id, + TenantId = user.TenantId, + UserName = user.UserName, + Name = user.Name, + Surname = user.Surname, + FullName = fullName.IsNullOrWhiteSpace() ? user.UserName : fullName, + Email = user.Email, + IsActive = user.IsActive + }; + }).ToList(); + } + + [HttpPost("api/app/messenger/upload-attachment")] + public async Task UploadAttachmentAsync([FromForm] MessengerUploadAttachmentInput input) + { + if (input.File is null || input.File.ContentLength is null or <= 0) + { + throw new UserFriendlyException("Dosya seçilmelidir."); + } + + var originalFileName = Path.GetFileName(input.File.FileName); + if (originalFileName.IsNullOrWhiteSpace()) + { + throw new UserFriendlyException("Dosya adı geçersiz."); + } + + var savedFileName = $"{Guid.NewGuid():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}" + }; + } +} diff --git a/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs b/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs new file mode 100644 index 0000000..402de24 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs @@ -0,0 +1,119 @@ +#nullable enable + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Guids; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Users; + +namespace Sozsoft.Platform.Messenger; + +[Authorize] +public class MessengerHub : Hub +{ + private readonly ICurrentTenant _currentTenant; + private readonly ICurrentUser _currentUser; + private readonly IGuidGenerator _guidGenerator; + private readonly IRepository _userRepository; + + public MessengerHub( + ICurrentTenant currentTenant, + ICurrentUser currentUser, + IGuidGenerator guidGenerator, + IRepository userRepository) + { + _currentTenant = currentTenant; + _currentUser = currentUser; + _guidGenerator = guidGenerator; + _userRepository = userRepository; + } + + public override async Task OnConnectedAsync() + { + if (_currentUser.Id.HasValue) + { + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroupName(_currentUser.Id.Value)); + await Groups.AddToGroupAsync(Context.ConnectionId, TenantGroupName()); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (_currentUser.Id.HasValue) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, UserGroupName(_currentUser.Id.Value)); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, TenantGroupName()); + } + + await base.OnDisconnectedAsync(exception); + } + + public async Task SendMessage(MessengerSendMessageDto input) + { + if (!_currentUser.Id.HasValue) + { + throw new HubException("Kullanıcı oturumu bulunamadı."); + } + + var recipientIds = input.RecipientIds + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (recipientIds.Count == 0) + { + throw new HubException("En az bir alıcı seçilmelidir."); + } + + if (input.Text.IsNullOrWhiteSpace() && input.Attachments.Count == 0) + { + throw new HubException("Mesaj veya dosya gönderilmelidir."); + } + + var tenantId = _currentTenant.Id; + var validRecipients = await _userRepository.GetListAsync(user => + recipientIds.Contains(user.Id) && + user.TenantId == tenantId && + user.IsActive); + + if (validRecipients.Count == 0) + { + throw new HubException("Bu tenant içinde geçerli alıcı bulunamadı."); + } + + var senderName = _currentUser.Name ?? _currentUser.UserName ?? "Kullanıcı"; + var message = new MessengerMessageDto + { + Id = _guidGenerator.Create(), + TenantId = tenantId, + SenderId = _currentUser.Id.Value, + SenderUserName = _currentUser.UserName ?? string.Empty, + SenderName = senderName, + RecipientIds = validRecipients.Select(user => user.Id).ToList(), + Text = input.Text?.Trim(), + Attachments = input.Attachments, + SentAt = DateTime.UtcNow + }; + + var targetGroups = message.RecipientIds + .Append(message.SenderId) + .Distinct() + .Select(UserGroupName) + .ToList(); + + await Clients.Groups(targetGroups).SendAsync("MessengerMessageReceived", message); + } + + private string TenantKey => _currentTenant.Id?.ToString("N") ?? "host"; + + private string TenantGroupName() => $"messenger:tenant:{TenantKey}"; + + private string UserGroupName(Guid userId) => $"messenger:user:{TenantKey}:{userId:N}"; +} diff --git a/api/src/Sozsoft.Platform.Domain/BlobStoring/BlobContainerNames.cs b/api/src/Sozsoft.Platform.Domain/BlobStoring/BlobContainerNames.cs index 962cbed..7280c2f 100644 --- a/api/src/Sozsoft.Platform.Domain/BlobStoring/BlobContainerNames.cs +++ b/api/src/Sozsoft.Platform.Domain/BlobStoring/BlobContainerNames.cs @@ -5,6 +5,7 @@ public static class BlobContainerNames public const string Intranet = "intranet"; public const string Avatar = "avatar"; public const string Import = "import"; + public const string Messenger = "messenger"; public const string Note = "note"; public const string Backup = "backup"; } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs index 1c4bc66..50bc78f 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PlatformHttpApiHostModule.cs @@ -62,6 +62,7 @@ using DevExpress.AspNetCore; using DevExpress.AspNetCore.Reporting; using DevExpress.XtraReports.Web.Extensions; using Sozsoft.Platform.ReportServices; +using Sozsoft.Platform.Messenger; namespace Sozsoft.Platform; @@ -483,6 +484,17 @@ public class PlatformHttpApiHostModule : AbpModule app.MapAbpStaticAssets(); app.UseRouting(); app.UseCors(); + app.Use(async (context, next) => + { + if (context.Request.Path.StartsWithSegments("/messengerhub") && + context.Request.Query.TryGetValue("access_token", out var accessToken) && + !string.IsNullOrWhiteSpace(accessToken.ToString())) + { + context.Request.Headers.Authorization = $"Bearer {accessToken}"; + } + + await next(); + }); app.UseAuthentication(); app.UseAbpOpenIddictValidation(); app.UseMiddleware(); @@ -522,7 +534,10 @@ public class PlatformHttpApiHostModule : AbpModule }); } - app.UseConfiguredEndpoints(); + app.UseConfiguredEndpoints(endpoints => + { + endpoints.MapHub("/messengerhub"); + }); } public override Task OnPostApplicationInitializationAsync(ApplicationInitializationContext context) diff --git a/configs/deployment/configs/nginx.conf b/configs/deployment/configs/nginx.conf index 61b3572..fd18d4c 100644 --- a/configs/deployment/configs/nginx.conf +++ b/configs/deployment/configs/nginx.conf @@ -73,6 +73,7 @@ server { server { listen 443 ssl http2; server_name api.sozsoft.com; + client_max_body_size 512M; ssl_certificate /etc/letsencrypt/live/api.sozsoft.com/fullchain.pem; # managed by Certbot ssl_trusted_certificate /etc/ssl/sozsoft.com/chain1.pem; ssl_certificate_key /etc/letsencrypt/live/api.sozsoft.com/privkey.pem; # managed by Certbot @@ -88,6 +89,18 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_set_header __tenant $http___tenant; + proxy_read_timeout 3600; + proxy_send_timeout 3600; + } + + location /messengerhub { + proxy_pass http://127.0.0.1:8080/messengerhub; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header __tenant $http___tenant; proxy_read_timeout 3600; proxy_send_timeout 3600; } @@ -197,4 +210,4 @@ server { } -} \ No newline at end of file +} diff --git a/ui/src/components/layouts/Layouts.tsx b/ui/src/components/layouts/Layouts.tsx index 136a511..f5d70ae 100644 --- a/ui/src/components/layouts/Layouts.tsx +++ b/ui/src/components/layouts/Layouts.tsx @@ -15,6 +15,7 @@ import useLocale from '@/utils/hooks/useLocale' import { useDynamicRoutes } from '@/routes/dynamicRoutesContext' import { useLocation } from 'react-router-dom' import { hasSubdomain } from '@/utils/subdomain' +import MessengerWidget from '../template/MessengerWidget' export type LayoutType = | typeof LAYOUT_TYPE_CLASSIC @@ -102,6 +103,7 @@ const Layout = () => { } > + {authenticated && currentPath.includes('/admin') && } ) } diff --git a/ui/src/components/template/MessengerWidget.tsx b/ui/src/components/template/MessengerWidget.tsx new file mode 100644 index 0000000..ec12dfa --- /dev/null +++ b/ui/src/components/template/MessengerWidget.tsx @@ -0,0 +1,442 @@ +import Avatar from '@/components/ui/Avatar' +import Button from '@/components/ui/Button' +import Input from '@/components/ui/Input' +import Notification from '@/components/ui/Notification' +import Tooltip from '@/components/ui/Tooltip' +import { toast } from '@/components/ui' +import { AVATAR_URL } from '@/constants/app.constant' +import { + getMessengerContacts, + MessengerAttachmentDto, + MessengerContactDto, + uploadMessengerAttachment, +} from '@/services/messenger.service' +import { messengerSignalR, MessengerMessageDto } from '@/services/messenger.signalr' +import { useStoreState } from '@/store' +import dayjs from 'dayjs' +import EmojiPicker, { EmojiClickData } from 'emoji-picker-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { FaArrowLeft, FaPaperclip, FaRegSmile, FaSearch, FaTimes, FaTrash } from 'react-icons/fa' +import { IoCheckmarkDone, IoChatbubbleEllipsesOutline, IoSend } from 'react-icons/io5' + +const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 + +const formatFileSize = (size: number) => { + if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +const MessengerWidget = () => { + const auth = useStoreState((state) => state.auth) + const tenantId = auth.user.tenantId || auth.tenant?.tenantId + const fileInputRef = useRef(null) + const messagesEndRef = useRef(null) + + const [open, setOpen] = useState(false) + const [contacts, setContacts] = useState([]) + const [selectedIds, setSelectedIds] = useState([]) + const [messages, setMessages] = useState([]) + const [text, setText] = useState('') + const [filter, setFilter] = useState('') + const [attachments, setAttachments] = useState([]) + const [connected, setConnected] = useState(false) + const [uploading, setUploading] = useState(false) + const [showEmoji, setShowEmoji] = useState(false) + const [multiSelect, setMultiSelect] = useState(false) + const [unread, setUnread] = useState(0) + + useEffect(() => { + if (!auth.session.signedIn) return + + const unsubscribeMessage = messengerSignalR.onMessage((message) => { + setMessages((prev) => (prev.some((item) => item.id === message.id) ? prev : [...prev, message])) + if (!open && message.senderId !== auth.user.id) { + setUnread((count) => count + 1) + } + }) + const unsubscribeState = messengerSignalR.onStateChange(setConnected) + messengerSignalR.start() + + return () => { + unsubscribeMessage() + unsubscribeState() + } + }, [auth.session.signedIn, auth.user.id, open]) + + useEffect(() => { + if (!auth.session.signedIn) { + messengerSignalR.stop() + } + }, [auth.session.signedIn]) + + useEffect(() => { + if (!open) return + setUnread(0) + getMessengerContacts(filter) + .then((response) => setContacts(response.data)) + .catch(() => setContacts([])) + }, [filter, open]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, open]) + + const selectedContacts = useMemo( + () => contacts.filter((contact) => selectedIds.includes(contact.id)), + [contacts, selectedIds], + ) + + const visibleMessages = useMemo(() => { + if (selectedIds.length === 0) return messages.slice(-30) + + 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)) + }) + }, [auth.user.id, messages, selectedIds]) + + const toggleContact = (id: string) => { + if (!multiSelect) { + setSelectedIds([id]) + return + } + + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])) + } + + const toggleMultiSelect = () => { + setMultiSelect((value) => { + if (value) { + setSelectedIds((prev) => prev.slice(0, 1)) + } + return !value + }) + } + + const handleFiles = async (files: FileList | null) => { + if (!files?.length) return + + const nextFiles = Array.from(files) + const tooLarge = nextFiles.find((file) => file.size > MAX_UPLOAD_SIZE) + if (tooLarge) { + toast.push(, { + placement: 'top-end', + }) + return + } + + setUploading(true) + try { + const uploaded = await Promise.all( + nextFiles.map((file) => uploadMessengerAttachment(file).then((response) => response.data)), + ) + setAttachments((prev) => [...prev, ...uploaded]) + } catch { + toast.push(, { placement: 'top-end' }) + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const sendMessage = async () => { + const trimmedText = text.trim() + if (selectedIds.length === 0 || (!trimmedText && attachments.length === 0)) return + + try { + await messengerSignalR.sendMessage({ + recipientIds: selectedIds, + text: trimmedText, + attachments, + }) + setText('') + setAttachments([]) + setShowEmoji(false) + } catch { + toast.push(, { placement: 'top-end' }) + } + } + + const onEmojiClick = (emoji: EmojiClickData) => { + setText((current) => `${current}${emoji.emoji}`) + } + + if (!auth.session.signedIn) return null + + return ( +
+ {open && ( +
+
+ + +
+
+
+
+
+ +
+ {visibleMessages.length === 0 && ( +
+ Henüz mesaj yok +
+ )} + + {visibleMessages.map((message) => { + const own = message.senderId === auth.user.id + return ( +
+
+ {!own &&
{message.senderName}
} + {message.text &&
{message.text}
} + {message.attachments.length > 0 && ( +
+ {message.attachments.map((file) => ( + + {file.fileName} + {formatFileSize(file.fileSize)} + + ))} +
+ )} +
+ {dayjs(message.sentAt).format('HH:mm')} +
+
+
+ ) + })} +
+
+ + {selectedIds.length > 0 && attachments.length > 0 && ( +
+ {attachments.map((file) => ( + + {file.fileName} + + + ))} +
+ )} + + {selectedIds.length > 0 && ( +
+ {showEmoji && ( +
+ +
+ )} +
+ handleFiles(event.target.files)} + /> + +