Messenger Online / Offline mekanizması

This commit is contained in:
Sedat Öztürk 2026-06-13 21:50:39 +03:00
parent af726c530a
commit 771ea95e55
11 changed files with 137 additions and 52 deletions

View file

@ -18,6 +18,7 @@ public class MessengerContactDto
public string FullName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty;
public string? Email { get; set; } public string? Email { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public bool IsOnline { get; set; }
} }
public class MessengerAttachmentDto public class MessengerAttachmentDto

View file

@ -9,6 +9,7 @@ public class OrgChartUserDto
public string FullName { get; set; } public string FullName { get; set; }
public string Email { get; set; } public string Email { get; set; }
public string UserName { get; set; } public string UserName { get; set; }
public string PhoneNumber { get; set; }
} }
public class OrgChartNodeDto public class OrgChartNodeDto
@ -21,5 +22,8 @@ public class OrgChartNodeDto
public Guid? DepartmentId { get; set; } public Guid? DepartmentId { get; set; }
public string DepartmentName { get; set; } public string DepartmentName { get; set; }
public Guid? JobPositionId { get; set; }
public string JobPositionName { get; set; }
public List<OrgChartUserDto> Users { get; set; } = []; public List<OrgChartUserDto> Users { get; set; } = [];
} }

View file

@ -26,6 +26,7 @@ public class MessengerAppService : ApplicationService
private readonly IRepository<IdentityUser, Guid> _userRepository; private readonly IRepository<IdentityUser, Guid> _userRepository;
private readonly IRepository<MessengerConversation, Guid> _conversationRepository; private readonly IRepository<MessengerConversation, Guid> _conversationRepository;
private readonly IRepository<MessengerConversationMessage, Guid> _messageRepository; private readonly IRepository<MessengerConversationMessage, Guid> _messageRepository;
private readonly IIdentitySessionRepository _identitySessionRepository;
private readonly BlobManager _blobManager; private readonly BlobManager _blobManager;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
@ -33,12 +34,14 @@ public class MessengerAppService : ApplicationService
IRepository<IdentityUser, Guid> userRepository, IRepository<IdentityUser, Guid> userRepository,
IRepository<MessengerConversation, Guid> conversationRepository, IRepository<MessengerConversation, Guid> conversationRepository,
IRepository<MessengerConversationMessage, Guid> messageRepository, IRepository<MessengerConversationMessage, Guid> messageRepository,
IIdentitySessionRepository identitySessionRepository,
BlobManager blobManager, BlobManager blobManager,
IConfiguration configuration) IConfiguration configuration)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_conversationRepository = conversationRepository; _conversationRepository = conversationRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_identitySessionRepository = identitySessionRepository;
_blobManager = blobManager; _blobManager = blobManager;
_configuration = configuration; _configuration = configuration;
} }
@ -71,6 +74,8 @@ public class MessengerAppService : ApplicationService
.Take(100) .Take(100)
); );
var onlineUserIds = await GetOnlineUserIdsAsync(users.Select(user => user.Id));
return users.Select(user => return users.Select(user =>
{ {
var fullName = $"{user.Name} {user.Surname}".Trim(); var fullName = $"{user.Name} {user.Surname}".Trim();
@ -83,7 +88,8 @@ public class MessengerAppService : ApplicationService
Surname = user.Surname, Surname = user.Surname,
FullName = fullName.IsNullOrWhiteSpace() ? user.UserName : fullName, FullName = fullName.IsNullOrWhiteSpace() ? user.UserName : fullName,
Email = user.Email, Email = user.Email,
IsActive = user.IsActive IsActive = user.IsActive,
IsOnline = onlineUserIds.Contains(user.Id)
}; };
}).ToList(); }).ToList();
} }
@ -238,6 +244,12 @@ public class MessengerAppService : ApplicationService
throw new UserFriendlyException("Bu tenant icinde gecersiz alici var."); throw new UserFriendlyException("Bu tenant icinde gecersiz alici var.");
} }
var onlineRecipientIds = await GetOnlineUserIdsAsync(recipientIds);
if (onlineRecipientIds.Count != recipientIds.Count)
{
throw new UserFriendlyException("Sadece online kullanicilara mesaj gonderebilirsiniz.");
}
MessengerConversation? conversation = null; MessengerConversation? conversation = null;
if (input.ConversationId.HasValue) if (input.ConversationId.HasValue)
{ {
@ -406,6 +418,25 @@ public class MessengerAppService : ApplicationService
user.IsActive); user.IsActive);
} }
private async Task<HashSet<Guid>> GetOnlineUserIdsAsync(IEnumerable<Guid> userIds)
{
var normalizedUserIds = userIds
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
if (normalizedUserIds.Count == 0)
{
return new HashSet<Guid>();
}
var sessions = await _identitySessionRepository.GetListAsync();
return sessions
.Where(session => normalizedUserIds.Contains(session.UserId))
.Select(session => session.UserId)
.ToHashSet();
}
private async Task<MessengerConversation?> FindConversationByParticipantKeyAsync(string participantKey) private async Task<MessengerConversation?> FindConversationByParticipantKeyAsync(string participantKey)
{ {
var query = await _conversationRepository.GetQueryableAsync(); var query = await _conversationRepository.GetQueryableAsync();

View file

@ -66,14 +66,20 @@ public class OrgChartAppService : PlatformAppService
topPositionByDepartment[department.Id] = selectedTop; topPositionByDepartment[department.Id] = selectedTop;
} }
var nodes = departments.Select(d => new OrgChartNodeDto var nodes = departments.Select(d =>
{
topPositionByDepartment.TryGetValue(d.Id, out var topPosition);
return new OrgChartNodeDto
{ {
Id = d.Id, Id = d.Id,
Name = d.Name, Name = d.Name,
ParentId = d.ParentId, ParentId = d.ParentId,
JobPositionId = topPosition?.Id,
JobPositionName = topPosition?.Name,
Users = users Users = users
.Where(u => .Where(u =>
topPositionByDepartment.TryGetValue(d.Id, out var topPosition) && topPosition != null &&
u.GetJobPositionId() == topPosition.Id) u.GetJobPositionId() == topPosition.Id)
.Select(u => new OrgChartUserDto .Select(u => new OrgChartUserDto
{ {
@ -81,8 +87,10 @@ public class OrgChartAppService : PlatformAppService
FullName = u.GetFullName(), FullName = u.GetFullName(),
Email = u.Email, Email = u.Email,
UserName = u.UserName, UserName = u.UserName,
PhoneNumber = u.PhoneNumber
}) })
.ToList(), .ToList(),
};
}).ToList(); }).ToList();
return nodes; return nodes;
@ -104,6 +112,8 @@ public class OrgChartAppService : PlatformAppService
ParentId = jp.ParentId, ParentId = jp.ParentId,
DepartmentId = jp.DepartmentId, DepartmentId = jp.DepartmentId,
DepartmentName = deptDict.TryGetValue(jp.DepartmentId, out var name) ? name : null, DepartmentName = deptDict.TryGetValue(jp.DepartmentId, out var name) ? name : null,
JobPositionId = jp.Id,
JobPositionName = jp.Name,
Users = users Users = users
.Where(u => u.GetJobPositionId() == jp.Id) .Where(u => u.GetJobPositionId() == jp.Id)
.Select(u => new OrgChartUserDto .Select(u => new OrgChartUserDto
@ -112,6 +122,7 @@ public class OrgChartAppService : PlatformAppService
FullName = u.GetFullName(), FullName = u.GetFullName(),
Email = u.Email, Email = u.Email,
UserName = u.UserName, UserName = u.UserName,
PhoneNumber = u.PhoneNumber
}) })
.ToList(), .ToList(),
}).ToList(); }).ToList();

View file

@ -1439,12 +1439,6 @@
"DepartmentName": "Yönetim", "DepartmentName": "Yönetim",
"Name": "Genel Müdür" "Name": "Genel Müdür"
}, },
{
"Id": "a2b3c4d5-e6f7-4890-ab12-cd34ef56ab78",
"DepartmentName": "Yönetim",
"Name": "Genel Müdür Yardımcısı",
"ParentName": "Genel Müdür"
},
{ {
"Id": "b7c8d9e0-f1a2-4b3c-8d9e-0f1a2b3c4d5e", "Id": "b7c8d9e0-f1a2-4b3c-8d9e-0f1a2b3c4d5e",
"DepartmentName": "Muhasebe", "DepartmentName": "Muhasebe",

View file

@ -31,6 +31,7 @@ import {
import { IoCheckmarkDone, IoChatbubbleEllipsesOutline, IoSend } from 'react-icons/io5' import { IoCheckmarkDone, IoChatbubbleEllipsesOutline, IoSend } from 'react-icons/io5'
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
const CONTACT_REFRESH_INTERVAL = 30000
const formatFileSize = (size: number) => { const formatFileSize = (size: number) => {
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB` if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`
@ -99,9 +100,25 @@ const MessengerWidget = () => {
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
let active = true
const loadContacts = () => {
getMessengerContacts(filter) getMessengerContacts(filter)
.then((response) => setContacts(response.data)) .then((response) => {
.catch(() => setContacts([])) if (active) setContacts(response.data)
})
.catch(() => {
if (active) setContacts([])
})
}
loadContacts()
const timer = window.setInterval(loadContacts, CONTACT_REFRESH_INTERVAL)
return () => {
active = false
window.clearInterval(timer)
}
}, [filter, open]) }, [filter, open])
useEffect(() => { useEffect(() => {
@ -135,6 +152,9 @@ const MessengerWidget = () => {
[contacts, selectedIds], [contacts, selectedIds],
) )
const canSendToSelectedContacts =
selectedContacts.length > 0 && selectedContacts.every((contact) => contact.isOnline)
const visibleMessages = useMemo(() => { const visibleMessages = useMemo(() => {
if (selectedIds.length === 0) return [] if (selectedIds.length === 0) return []
@ -146,6 +166,14 @@ const MessengerWidget = () => {
}, [auth.user.id, messages, selectedIds]) }, [auth.user.id, messages, selectedIds])
const toggleContact = (id: string) => { const toggleContact = (id: string) => {
const contact = contacts.find((item) => item.id === id)
if (!contact?.isOnline) {
toast.push(<Notification title="Offline kullanıcılara mesaj gönderilemez" type="warning" />, {
placement: 'top-end',
})
return
}
setUnreadByContact((current) => { setUnreadByContact((current) => {
if (!current[id]) return current if (!current[id]) return current
@ -210,6 +238,12 @@ const MessengerWidget = () => {
const sendMessage = async () => { const sendMessage = async () => {
const trimmedText = text.trim() const trimmedText = text.trim()
if (selectedIds.length === 0 || (!trimmedText && attachments.length === 0)) return if (selectedIds.length === 0 || (!trimmedText && attachments.length === 0)) return
if (!canSendToSelectedContacts) {
toast.push(<Notification title="Sadece online kullanıcılara mesaj gönderebilirsiniz" type="warning" />, {
placement: 'top-end',
})
return
}
try { try {
await messengerSignalR.sendMessage({ await messengerSignalR.sendMessage({
@ -311,16 +345,31 @@ const MessengerWidget = () => {
key={contact.id} key={contact.id}
type="button" type="button"
onClick={() => toggleContact(contact.id)} onClick={() => toggleContact(contact.id)}
disabled={!contact.isOnline}
className={`mb-1 flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition ${ className={`mb-1 flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition ${
active active
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100'
: 'hover:bg-white dark:hover:bg-gray-800' : contact.isOnline
? 'hover:bg-white dark:hover:bg-gray-800'
: 'cursor-not-allowed opacity-60'
}`} }`}
> >
<Avatar size={36} shape="circle" src={AVATAR_URL(contact.id, contact.tenantId || tenantId)} /> <Avatar size={36} shape="circle" src={AVATAR_URL(contact.id, contact.tenantId || tenantId)} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{contact.fullName}</span> <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 className="block truncate text-xs text-gray-500">{contact.email || contact.userName}</span>
<span
className={`mt-1 inline-flex items-center gap-1 text-xs font-medium ${
contact.isOnline ? 'text-emerald-600' : 'text-red-500'
}`}
>
<span
className={`h-2 w-2 rounded-full ${
contact.isOnline ? 'bg-emerald-500' : 'bg-red-500'
}`}
/>
{contact.isOnline ? 'Online' : 'Offline'}
</span>
</span> </span>
{contactUnread > 0 && ( {contactUnread > 0 && (
<span className="shrink-0 rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white"> <span className="shrink-0 rounded-full bg-red-500 px-2 py-0.5 text-xs font-semibold text-white">
@ -370,9 +419,13 @@ const MessengerWidget = () => {
</div> </div>
<div className="truncate text-xs text-gray-500"> <div className="truncate text-xs text-gray-500">
{selectedContacts.length === 1 {selectedContacts.length === 1
? selectedContacts[0].email || selectedContacts[0].userName ? `${selectedContacts[0].isOnline ? 'Online' : 'Offline'} - ${
selectedContacts[0].email || selectedContacts[0].userName
}`
: selectedContacts.length > 1 : selectedContacts.length > 1
? canSendToSelectedContacts
? 'Toplu mesaj' ? 'Toplu mesaj'
: 'Offline kullanici secili'
: 'Mesaj göndermek için kişi seçin'} : 'Mesaj göndermek için kişi seçin'}
</div> </div>
</div> </div>
@ -518,6 +571,7 @@ const MessengerWidget = () => {
loading={uploading} loading={uploading}
icon={<FaPaperclip />} icon={<FaPaperclip />}
className="shrink-0" className="shrink-0"
disabled={!canSendToSelectedContacts}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
/> />
</Tooltip> </Tooltip>
@ -537,6 +591,7 @@ const MessengerWidget = () => {
placeholder="Mesaj yazın" placeholder="Mesaj yazın"
rows={1} rows={1}
autoFocus autoFocus
disabled={!canSendToSelectedContacts}
className="min-h-[36px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-100" className="min-h-[36px] flex-1 resize-none border-0 bg-transparent px-2 py-2 text-sm text-gray-900 outline-none placeholder:text-gray-400 focus:ring-0 dark:text-gray-100"
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
@ -552,7 +607,7 @@ const MessengerWidget = () => {
variant="solid" variant="solid"
icon={<IoSend />} icon={<IoSend />}
className="shrink-0" className="shrink-0"
disabled={!text.trim() && attachments.length === 0} disabled={!canSendToSelectedContacts || (!text.trim() && attachments.length === 0)}
onClick={sendMessage} onClick={sendMessage}
/> />
</div> </div>

View file

@ -9,6 +9,7 @@ export interface MessengerContactDto {
fullName: string fullName: string
email?: string email?: string
isActive: boolean isActive: boolean
isOnline: boolean
} }
export interface MessengerAttachmentDto { export interface MessengerAttachmentDto {

View file

@ -5,6 +5,7 @@ export interface OrgChartUserDto {
fullName: string fullName: string
email: string email: string
userName: string userName: string
phoneNumber?: string
} }
export interface OrgChartNodeDto { export interface OrgChartNodeDto {
@ -13,6 +14,8 @@ export interface OrgChartNodeDto {
parentId?: string parentId?: string
departmentId?: string departmentId?: string
departmentName?: string departmentName?: string
jobPositionId?: string
jobPositionName?: string
users: OrgChartUserDto[] users: OrgChartUserDto[]
} }

View file

@ -342,12 +342,12 @@ function buildEdges(tree: TreeNode[]): EdgeLink[] {
function UserAvatar({ function UserAvatar({
user, user,
tenantId, tenantId,
title, jobPosition,
department, department,
}: { }: {
user: OrgChartUserDto user: OrgChartUserDto
tenantId?: string tenantId?: string
title: string jobPosition?: string
department?: string department?: string
}) { }) {
const [showUserCard, setShowUserCard] = useState(false) const [showUserCard, setShowUserCard] = useState(false)
@ -381,8 +381,9 @@ function UserAvatar({
user={{ user={{
id: user.id, id: user.id,
name: user.fullName || user.userName, name: user.fullName || user.userName,
title, title: jobPosition || '',
email: user.email, email: user.email,
phoneNumber: user.phoneNumber,
department, department,
tenantId: tenantId ?? '', tenantId: tenantId ?? '',
}} }}
@ -518,16 +519,7 @@ function OrgChartNode({
)} )}
</div> </div>
{/* Department label for job positions */}
{mode === 'jobPosition' && node.departmentName && (
<div className="px-3 pt-2">
<span
className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${badgeBg}`}
>
{node.departmentName}
</span>
</div>
)}
{/* Users section */} {/* Users section */}
{showUsers && ( {showUsers && (
@ -539,7 +531,7 @@ function OrgChartNode({
<UserAvatar <UserAvatar
user={user} user={user}
tenantId={tenantId} tenantId={tenantId}
title={mode === 'jobPosition' ? node.name : node.departmentName || node.name} jobPosition={node.jobPositionName || (mode === 'jobPosition' ? node.name : undefined)}
department={mode === 'jobPosition' ? node.departmentName : node.name} department={mode === 'jobPosition' ? node.departmentName : node.name}
/> />
<span data-user-name="" className="text-xs text-slate-600 truncate"> <span data-user-name="" className="text-xs text-slate-600 truncate">

View file

@ -386,6 +386,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
id: comment.user?.id || '', id: comment.user?.id || '',
name: comment.user?.fullName || '', name: comment.user?.fullName || '',
title: comment.user?.jobPositions?.[0]?.name || '', title: comment.user?.jobPositions?.[0]?.name || '',
phoneNumber: comment.user.phoneNumber,
tenantId: comment.user?.tenantId || '', tenantId: comment.user?.tenantId || '',
}} }}
position="bottom" position="bottom"

View file

@ -12,7 +12,6 @@ interface UserProfileCardProps {
email?: string email?: string
phoneNumber?: string phoneNumber?: string
department?: string department?: string
location?: string
tenantId: string tenantId: string
} }
position?: 'top' | 'bottom' position?: 'top' | 'bottom'
@ -76,13 +75,6 @@ const UserProfileCard: React.FC<UserProfileCardProps> = ({ user, position = 'bot
<span className="text-gray-700 dark:text-gray-300">{user.department}</span> <span className="text-gray-700 dark:text-gray-300">{user.department}</span>
</div> </div>
)} )}
{user.location && (
<div className="flex items-center gap-2 text-sm">
<FaMapMarkerAlt className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-gray-700 dark:text-gray-300">{user.location}</span>
</div>
)}
</div> </div>
{/* Arrow indicator */} {/* Arrow indicator */}