Messenger Hub
This commit is contained in:
parent
0c202ece24
commit
7632c9e8a0
10 changed files with 915 additions and 2 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
119
api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs
Normal file
119
api/src/Sozsoft.Platform.Application/Messenger/MessengerHub.cs
Normal 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}";
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CaptchaMiddleware>();
|
||||
|
|
@ -522,7 +534,10 @@ public class PlatformHttpApiHostModule : AbpModule
|
|||
});
|
||||
}
|
||||
|
||||
app.UseConfiguredEndpoints();
|
||||
app.UseConfiguredEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapHub<MessengerHub>("/messengerhub");
|
||||
});
|
||||
}
|
||||
|
||||
public override Task OnPostApplicationInitializationAsync(ApplicationInitializationContext context)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
}
|
||||
>
|
||||
<AppLayout />
|
||||
{authenticated && currentPath.includes('/admin') && <MessengerWidget />}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
442
ui/src/components/template/MessengerWidget.tsx
Normal file
442
ui/src/components/template/MessengerWidget.tsx
Normal 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
|
||||
38
ui/src/services/messenger.service.ts
Normal file
38
ui/src/services/messenger.service.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
110
ui/src/services/messenger.signalr.ts
Normal file
110
ui/src/services/messenger.signalr.ts
Normal 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()
|
||||
Loading…
Reference in a new issue