Messenger Hub

This commit is contained in:
Sedat Öztürk 2026-06-12 20:31:41 +03:00
parent 0c202ece24
commit 7632c9e8a0
10 changed files with 915 additions and 2 deletions

View file

@ -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<Guid> RecipientIds { get; set; } = new();
[StringLength(4096)]
public string? Text { get; set; }
public List<MessengerAttachmentDto> 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<Guid> RecipientIds { get; set; } = new();
public string? Text { get; set; }
public List<MessengerAttachmentDto> Attachments { get; set; } = new();
public DateTime SentAt { get; set; }
}

View file

@ -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<IdentityUser, Guid> _userRepository;
private readonly BlobManager _blobManager;
private readonly IConfiguration _configuration;
public MessengerAppService(
IRepository<IdentityUser, Guid> userRepository,
BlobManager blobManager,
IConfiguration configuration)
{
_userRepository = userRepository;
_blobManager = blobManager;
_configuration = configuration;
}
public async Task<List<MessengerContactDto>> 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<MessengerAttachmentDto> 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}"
};
}
}

View file

@ -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<IdentityUser, Guid> _userRepository;
public MessengerHub(
ICurrentTenant currentTenant,
ICurrentUser currentUser,
IGuidGenerator guidGenerator,
IRepository<IdentityUser, Guid> 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}";
}

View file

@ -5,6 +5,7 @@ public static class BlobContainerNames
public const string Intranet = "intranet"; public const string Intranet = "intranet";
public const string Avatar = "avatar"; public const string Avatar = "avatar";
public const string Import = "import"; public const string Import = "import";
public const string Messenger = "messenger";
public const string Note = "note"; public const string Note = "note";
public const string Backup = "backup"; public const string Backup = "backup";
} }

View file

@ -62,6 +62,7 @@ using DevExpress.AspNetCore;
using DevExpress.AspNetCore.Reporting; using DevExpress.AspNetCore.Reporting;
using DevExpress.XtraReports.Web.Extensions; using DevExpress.XtraReports.Web.Extensions;
using Sozsoft.Platform.ReportServices; using Sozsoft.Platform.ReportServices;
using Sozsoft.Platform.Messenger;
namespace Sozsoft.Platform; namespace Sozsoft.Platform;
@ -483,6 +484,17 @@ public class PlatformHttpApiHostModule : AbpModule
app.MapAbpStaticAssets(); app.MapAbpStaticAssets();
app.UseRouting(); app.UseRouting();
app.UseCors(); 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.UseAuthentication();
app.UseAbpOpenIddictValidation(); app.UseAbpOpenIddictValidation();
app.UseMiddleware<CaptchaMiddleware>(); app.UseMiddleware<CaptchaMiddleware>();
@ -522,7 +534,10 @@ public class PlatformHttpApiHostModule : AbpModule
}); });
} }
app.UseConfiguredEndpoints(); app.UseConfiguredEndpoints(endpoints =>
{
endpoints.MapHub<MessengerHub>("/messengerhub");
});
} }
public override Task OnPostApplicationInitializationAsync(ApplicationInitializationContext context) public override Task OnPostApplicationInitializationAsync(ApplicationInitializationContext context)

View file

@ -73,6 +73,7 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name api.sozsoft.com; server_name api.sozsoft.com;
client_max_body_size 512M;
ssl_certificate /etc/letsencrypt/live/api.sozsoft.com/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/api.sozsoft.com/fullchain.pem; # managed by Certbot
ssl_trusted_certificate /etc/ssl/sozsoft.com/chain1.pem; ssl_trusted_certificate /etc/ssl/sozsoft.com/chain1.pem;
ssl_certificate_key /etc/letsencrypt/live/api.sozsoft.com/privkey.pem; # managed by Certbot 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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; 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_read_timeout 3600;
proxy_send_timeout 3600; proxy_send_timeout 3600;
} }
@ -197,4 +210,4 @@ server {
} }
} }

View file

@ -15,6 +15,7 @@ import useLocale from '@/utils/hooks/useLocale'
import { useDynamicRoutes } from '@/routes/dynamicRoutesContext' import { useDynamicRoutes } from '@/routes/dynamicRoutesContext'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { hasSubdomain } from '@/utils/subdomain' import { hasSubdomain } from '@/utils/subdomain'
import MessengerWidget from '../template/MessengerWidget'
export type LayoutType = export type LayoutType =
| typeof LAYOUT_TYPE_CLASSIC | typeof LAYOUT_TYPE_CLASSIC
@ -102,6 +103,7 @@ const Layout = () => {
} }
> >
<AppLayout /> <AppLayout />
{authenticated && currentPath.includes('/admin') && <MessengerWidget />}
</Suspense> </Suspense>
) )
} }

View file

@ -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<HTMLInputElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [contacts, setContacts] = useState<MessengerContactDto[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [messages, setMessages] = useState<MessengerMessageDto[]>([])
const [text, setText] = useState('')
const [filter, setFilter] = useState('')
const [attachments, setAttachments] = useState<MessengerAttachmentDto[]>([])
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(<Notification title="Dosya 10 MB sınırını aşamaz" type="warning" />, {
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(<Notification title="Dosya yüklenemedi" type="danger" />, { 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(<Notification title="Mesaj gönderilemedi" type="danger" />, { placement: 'top-end' })
}
}
const onEmojiClick = (emoji: EmojiClickData) => {
setText((current) => `${current}${emoji.emoji}`)
}
if (!auth.session.signedIn) return null
return (
<div className="pointer-events-none fixed bottom-5 right-5 z-50 h-14 w-14">
{open && (
<div className="pointer-events-auto fixed inset-x-2 bottom-20 top-3 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 sm:absolute sm:inset-auto sm:bottom-16 sm:right-0 sm:h-[min(70vh,640px)] sm:w-[min(78vw,800px)]">
<div className="flex h-full min-h-0">
<aside
className={`min-h-0 w-full shrink-0 flex-col border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 sm:flex sm:w-72 sm:border-r ${
selectedIds.length > 0 ? 'hidden' : 'flex'
}`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-700">
<div className="flex min-w-0 items-center gap-3">
<Avatar size={36} shape="circle" src={auth.user.avatar || AVATAR_URL(auth.user.id, tenantId)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{auth.user.name || auth.user.userName || 'Messenger'}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500">
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-emerald-500' : 'bg-gray-400'}`} />
{connected ? 'Bağlı' : 'Bağlanıyor'}
</div>
</div>
</div>
<Button size="xs" variant="plain" icon={<FaTimes />} onClick={() => setOpen(false)} />
</div>
<div className="space-y-2 p-3">
<Input
size="sm"
value={filter}
prefix={<FaSearch />}
placeholder="Kişi ara"
onChange={(event) => setFilter(event.target.value)}
/>
<Button
size="xs"
variant={multiSelect ? 'twoTone' : 'default'}
block
onClick={toggleMultiSelect}
>
{multiSelect ? 'Çoklu seçim açık' : 'Çoklu seçim'}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{contacts.map((contact) => {
const active = selectedIds.includes(contact.id)
return (
<button
key={contact.id}
type="button"
onClick={() => toggleContact(contact.id)}
className={`mb-1 flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition ${
active
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100'
: 'hover:bg-white dark:hover:bg-gray-800'
}`}
>
<Avatar size={36} shape="circle" src={AVATAR_URL(contact.id, contact.tenantId || tenantId)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{contact.fullName}</span>
<span className="block truncate text-xs text-gray-500">{contact.email || contact.userName}</span>
</span>
{active && <IoCheckmarkDone className="shrink-0 text-lg" />}
</button>
)
})}
</div>
</aside>
<main
className={`min-w-0 flex-1 flex-col ${
selectedIds.length === 0 ? 'hidden sm:flex' : 'flex'
}`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-700">
<div className="flex min-w-0 items-center gap-3">
<Button
size="xs"
variant="plain"
icon={<FaArrowLeft />}
className="shrink-0 sm:hidden"
onClick={() => setSelectedIds([])}
/>
{selectedContacts.length === 1 && (
<Avatar
size={36}
shape="circle"
src={AVATAR_URL(selectedContacts[0].id, selectedContacts[0].tenantId || tenantId)}
/>
)}
{selectedContacts.length > 1 && (
<Avatar size={36} shape="circle" className="bg-emerald-100 text-emerald-700">
{selectedContacts.length}
</Avatar>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{selectedContacts.length === 1
? selectedContacts[0].fullName
: selectedContacts.length > 1
? `${selectedContacts.length} kişi seçildi`
: 'Sohbet'}
</div>
<div className="truncate text-xs text-gray-500">
{selectedContacts.length === 1
? selectedContacts[0].email || selectedContacts[0].userName
: selectedContacts.length > 1
? 'Toplu mesaj'
: 'Mesaj göndermek için kişi seçin'}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto bg-white px-4 py-3 dark:bg-gray-800">
{visibleMessages.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
Henüz mesaj yok
</div>
)}
{visibleMessages.map((message) => {
const own = message.senderId === auth.user.id
return (
<div key={message.id} className={`mb-3 flex ${own ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[76%] rounded-lg px-3 py-2 shadow-sm ${
own
? 'bg-emerald-600 text-white'
: 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
}`}
>
{!own && <div className="mb-1 text-xs font-semibold">{message.senderName}</div>}
{message.text && <div className="whitespace-pre-wrap text-sm leading-5">{message.text}</div>}
{message.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{message.attachments.map((file) => (
<a
key={file.savedFileName}
href={file.url}
target="_blank"
rel="noreferrer"
className={`block rounded-md px-2 py-1 text-xs ${
own ? 'bg-emerald-700 text-white' : 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-100'
}`}
>
<span className="block truncate">{file.fileName}</span>
<span className="opacity-75">{formatFileSize(file.fileSize)}</span>
</a>
))}
</div>
)}
<div className={`mt-1 text-[11px] ${own ? 'text-emerald-100' : 'text-gray-500'}`}>
{dayjs(message.sentAt).format('HH:mm')}
</div>
</div>
</div>
)
})}
<div ref={messagesEndRef} />
</div>
{selectedIds.length > 0 && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-gray-200 px-4 py-2 dark:border-gray-700">
{attachments.map((file) => (
<span
key={file.savedFileName}
className="inline-flex max-w-[220px] items-center gap-2 rounded-md bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
>
<span className="truncate">{file.fileName}</span>
<button
type="button"
onClick={() =>
setAttachments((prev) =>
prev.filter((item) => item.savedFileName !== file.savedFileName),
)
}
>
<FaTrash />
</button>
</span>
))}
</div>
)}
{selectedIds.length > 0 && (
<div className="relative border-t border-gray-200 p-3 dark:border-gray-700">
{showEmoji && (
<div className="absolute bottom-16 left-2 z-10 sm:left-10">
<EmojiPicker
onEmojiClick={onEmojiClick}
width="min(320px, calc(100vw - 32px))"
height={380}
/>
</div>
)}
<div className="flex min-h-[52px] items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 focus-within:border-emerald-500 focus-within:ring-1 focus-within:ring-emerald-500 dark:border-gray-600 dark:bg-gray-800">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => handleFiles(event.target.files)}
/>
<Tooltip title="Dosya ekle">
<Button
type="button"
size="sm"
variant="plain"
loading={uploading}
icon={<FaPaperclip />}
className="shrink-0"
onClick={() => fileInputRef.current?.click()}
/>
</Tooltip>
<Tooltip title="Emoji">
<Button
type="button"
size="sm"
variant="plain"
icon={<FaRegSmile />}
className="shrink-0"
onClick={() => setShowEmoji((value) => !value)}
/>
</Tooltip>
<textarea
value={text}
placeholder="Mesaj yazın"
rows={1}
autoFocus
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)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}}
/>
<Button
type="button"
size="sm"
variant="solid"
icon={<IoSend />}
className="shrink-0"
disabled={!text.trim() && attachments.length === 0}
onClick={sendMessage}
/>
</div>
</div>
)}
</main>
</div>
</div>
)}
<Tooltip title="Messenger">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="pointer-events-auto relative flex h-12 w-12 items-center justify-center rounded-full bg-emerald-600 text-2xl text-white shadow-xl transition hover:bg-emerald-700"
>
<IoChatbubbleEllipsesOutline />
{unread > 0 && (
<span className="absolute min-w-[20px] rounded-full bg-red-500 px-1.5 py-0.5 text-xs font-semibold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
</Tooltip>
</div>
)
}
export default MessengerWidget

View file

@ -0,0 +1,38 @@
import apiService from './api.service'
export interface MessengerContactDto {
id: string
tenantId?: string
userName: string
name?: string
surname?: string
fullName: string
email?: string
isActive: boolean
}
export interface MessengerAttachmentDto {
fileName: string
savedFileName: string
fileType: string
fileSize: number
url: string
}
export const getMessengerContacts = (filter?: string) =>
apiService.fetchData<MessengerContactDto[]>({
method: 'GET',
url: '/api/app/messenger/contacts',
params: filter ? { filter } : undefined,
})
export const uploadMessengerAttachment = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return apiService.fetchData<MessengerAttachmentDto>({
method: 'POST',
url: '/api/app/messenger/upload-attachment',
data: formData,
})
}

View file

@ -0,0 +1,110 @@
import { store } from '@/store/store'
import * as signalR from '@microsoft/signalr'
import { MessengerAttachmentDto } from './messenger.service'
export interface MessengerMessageDto {
id: string
tenantId?: string
senderId: string
senderUserName: string
senderName: string
recipientIds: string[]
text?: string
attachments: MessengerAttachmentDto[]
sentAt: string
}
export interface MessengerSendMessageDto {
recipientIds: string[]
text?: string
attachments: MessengerAttachmentDto[]
}
type MessageHandler = (message: MessengerMessageDto) => void
type StateHandler = (connected: boolean) => void
class MessengerSignalRService {
private connection?: signalR.HubConnection
private messageHandlers = new Set<MessageHandler>()
private stateHandlers = new Set<StateHandler>()
private createConnection() {
const { auth } = store.getState()
this.connection = new signalR.HubConnectionBuilder()
.withUrl(`${import.meta.env.VITE_API_URL}/messengerhub`, {
accessTokenFactory: () => store.getState().auth.session.token || auth.session.token || '',
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
.build()
this.connection.on('MessengerMessageReceived', (message: MessengerMessageDto) => {
this.messageHandlers.forEach((handler) => handler(message))
})
this.connection.onreconnected(() => this.emitState(true))
this.connection.onreconnecting(() => this.emitState(false))
this.connection.onclose(() => this.emitState(false))
}
async start() {
if (!store.getState().auth.session.signedIn) return
if (!this.connection) {
this.createConnection()
}
if (this.connection?.state === signalR.HubConnectionState.Connected) {
this.emitState(true)
return
}
if (this.connection?.state === signalR.HubConnectionState.Connecting) return
try {
await this.connection?.start()
this.emitState(true)
} catch {
this.emitState(false)
}
}
async stop() {
if (!this.connection) return
await this.connection.stop()
this.connection = undefined
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)
}
onMessage(handler: MessageHandler) {
this.messageHandlers.add(handler)
return () => this.messageHandlers.delete(handler)
}
onStateChange(handler: StateHandler) {
this.stateHandlers.add(handler)
handler(this.getConnectionState())
return () => this.stateHandlers.delete(handler)
}
getConnectionState() {
return this.connection?.state === signalR.HubConnectionState.Connected
}
private emitState(connected: boolean) {
this.stateHandlers.forEach((handler) => handler(connected))
}
}
export const messengerSignalR = new MessengerSignalRService()