Files
IKAR/src/Ikar.Server/Infrastructure/DtoMapper.cs
T

809 lines
29 KiB
C#

using Ikar.Server.Data.Entities;
using Ikar.Shared;
namespace Ikar.Server.Infrastructure;
public static class DtoMapper
{
public static UserSummaryDto ToDto(this User user, PresenceTracker presenceTracker) =>
new(
user.Id,
user.Username,
user.DisplayName,
presenceTracker.IsOnline(user.Id),
user.PhoneNumber,
user.Sessions
.OrderByDescending(x => x.LastSeenAt)
.Select(x => (DateTimeOffset?)x.LastSeenAt)
.FirstOrDefault(),
user.About,
ResolveAvatarPath(user),
user.IsBot);
public static UserSummaryDto ToDtoForViewer(
this User user,
PresenceTracker presenceTracker,
Guid currentUserId,
bool sharesChatWithViewer)
{
var dto = user.ToDto(presenceTracker);
if (user.Id == currentUserId)
{
return dto;
}
var privacy = user.PrivacySettings;
var canViewPhone = CanViewFieldByPrivacy(
currentUserId,
privacy?.PhoneNumberVisibility,
privacy?.AlwaysAllowUserIds,
privacy?.NeverAllowUserIds,
fallback: "contacts",
sharesChatWithViewer);
var canViewLastSeen = CanViewFieldByPrivacy(
currentUserId,
privacy?.LastSeenVisibility,
privacy?.AlwaysAllowUserIds,
privacy?.NeverAllowUserIds,
fallback: "contacts",
sharesChatWithViewer);
var canViewAvatar = CanViewFieldByPrivacy(
currentUserId,
privacy?.ProfilePhotoVisibility,
privacy?.AlwaysAllowUserIds,
privacy?.NeverAllowUserIds,
fallback: "everyone",
sharesChatWithViewer);
return dto with
{
PhoneNumber = canViewPhone ? dto.PhoneNumber : null,
LastSeenAt = canViewLastSeen ? dto.LastSeenAt : null,
IsOnline = canViewLastSeen && dto.IsOnline,
AvatarPath = canViewAvatar ? dto.AvatarPath : null
};
}
public static BotSummaryDto ToDto(this Bot bot) =>
new(
bot.Id,
bot.UserId,
bot.User.Username,
bot.User.DisplayName,
bot.User.About,
bot.IsEnabled,
bot.CreatedAt,
bot.Commands
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase)
.Select(x => new BotCommandDto(x.Command, x.Description))
.ToList());
public static PushDeviceDto ToDto(this PushDevice device) =>
new(
device.Id,
device.Platform,
device.InstallationId,
device.DeviceName,
device.NotificationsEnabled,
device.UpdatedAt);
public static MessageDto ToDto(this Message message, PresenceTracker presenceTracker, Guid currentUserId)
{
var sender = ResolveMessageSenderDto(message, presenceTracker, currentUserId);
var displayAuthorName = ResolveDisplayAuthorName(message);
var isChannelPost = message.Chat?.Type == ChatType.Channel && message.PostedAsChannel;
var viewCount = isChannelPost ? message.Views.Count : (int?)null;
var commentCount = isChannelPost && message.DiscussionMessageId is not null
? message.CommentCount
: (int?)null;
return new MessageDto(
message.Id,
message.ChatId,
message.Chat?.Type ?? ChatType.Direct,
sender,
message.Text,
message.Attachments
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.OriginalFileName)
.Select(x => x.ToDto(message.ChatId))
.ToList(),
message.SentAt,
message.EditedAt,
message.DeletedAt,
ResolveDeliveryState(message, currentUserId),
message.ForwardedFromDisplayName,
BuildReplyDto(message.ReplyToMessage, message.Chat),
message.PinnedAt,
message.PinnedByUser?.ToDtoForViewer(
presenceTracker,
currentUserId,
SharesChatWithCurrentUser(message.Chat, currentUserId)),
message.MediaAlbumId,
BuildReactions(message, currentUserId),
message.HasProtectedContent || message.Chat?.HasProtectedContent == true,
message.ExpiresAt,
message.TtlSeconds,
message.PostedAsChannel,
displayAuthorName,
viewCount,
BuildStoryReplyDto(message),
message.DiscussionMessageId,
commentCount,
message.ClientMessageId);
}
public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) =>
new(
attachment.Id,
attachment.OriginalFileName,
attachment.ContentType,
attachment.FileSizeBytes,
$"/api/chats/{chatId}/attachments/{attachment.Id}/content",
ResolveAttachmentKind(attachment),
attachment.SortOrder);
public static ChatSummaryDto ToSummaryDto(
this Chat chat,
Guid currentUserId,
PresenceTracker presenceTracker,
Message? latestMessage = null,
int? unreadCountOverride = null)
{
var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId);
var permissions = BuildPermissions(currentMembership, chat.Type);
var canSendMessages = permissions.CanPostMessages;
var channelStats = BuildChannelStats(chat);
var visibleMembers = BuildVisibleMembers(chat, currentMembership);
var orderedMembers = visibleMembers
.Select(x => x.User)
.OrderBy(x => x.DisplayName)
.ToList();
var sharesChatWithCurrentUser = currentMembership is not null;
var participants = orderedMembers
.Select(user => user.ToDtoForViewer(presenceTracker, currentUserId, sharesChatWithCurrentUser))
.ToList();
var counterpart = chat.Type == ChatType.Direct
? orderedMembers.FirstOrDefault(x => x.Id != currentUserId)
: null;
var title = chat.Type == ChatType.Direct
? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title)
: string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title;
var secondary = chat.Type switch
{
ChatType.Direct => counterpart is null
? null
: counterpart.IsBot
? $"бот · @{counterpart.Username}"
: $"@{counterpart.Username}",
ChatType.Channel => $"{FormatSubscribers(channelStats.SubscriberCount)}{(canSendMessages ? " · публикация разрешена" : string.Empty)}",
_ => $"{participants.Count} members"
};
var lastMessage = latestMessage ?? chat.Messages.OrderByDescending(x => x.SentAt).FirstOrDefault();
if (lastMessage is not null)
{
lastMessage.Chat ??= chat;
}
var lastPreview = lastMessage is null ? null : BuildMessagePreview(lastMessage);
var lastMessageAttachment = lastMessage is null ||
lastMessage.DeletedAt is not null ||
!string.IsNullOrWhiteSpace(lastMessage.Text)
? null
: lastMessage.Attachments
.OrderBy(attachment => attachment.SortOrder)
.ThenBy(attachment => attachment.Id)
.FirstOrDefault()
?.ToDto(chat.Id);
var unreadCount = unreadCountOverride ?? chat.Messages.Count(message =>
message.SenderId != currentUserId &&
message.DeletedAt is null &&
(currentMembership?.LastReadAt is null || message.SentAt > currentMembership.LastReadAt.Value));
return new ChatSummaryDto(
chat.Id,
title,
chat.Type,
secondary,
chat.LastActivityAt ?? chat.CreatedAt,
lastPreview,
participants,
canSendMessages,
unreadCount,
currentMembership?.IsPinned == true,
currentMembership?.IsArchived == true,
currentMembership?.MutedUntil is not null && currentMembership.MutedUntil > DateTimeOffset.UtcNow,
currentMembership?.FolderKey,
chat.HasProtectedContent,
chat.DefaultMessageTtlSeconds,
lastMessageAttachment,
channelStats.SubscriberCount,
channelStats.AdminCount,
currentMembership is not null,
chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername),
permissions.CanManageMembers,
chat.Type == ChatType.Channel && canSendMessages,
chat.PublicUsername,
chat.LinkedDiscussionChatId,
chat.ChannelSignaturesEnabled,
chat.Description,
CanDeleteForEveryone(chat, currentMembership));
}
public static ChatDetailsDto ToDetailsDto(
this Chat chat,
Guid currentUserId,
PresenceTracker presenceTracker,
Guid? firstUnreadMessageId = null,
IReadOnlyList<Message>? messagesOverride = null,
bool hasMoreMessages = false,
IReadOnlyList<Message>? pinnedMessagesOverride = null)
{
var currentMembership = chat.Members.FirstOrDefault(x => x.UserId == currentUserId);
var permissions = BuildPermissions(currentMembership, chat.Type);
var canSendMessages = permissions.CanPostMessages;
var channelStats = BuildChannelStats(chat);
var visibleMembers = BuildVisibleMembers(chat, currentMembership);
var participants = visibleMembers
.Select(x => x.User)
.OrderBy(x => x.DisplayName)
.Select(x => x.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null))
.ToList();
var members = visibleMembers
.OrderByDescending(x => x.IsOwner || x.Role == ChatMemberRole.Owner)
.ThenByDescending(x => x.Role == ChatMemberRole.Admin)
.ThenBy(x => x.User.DisplayName)
.Select(x => new ChatMemberDto(
x.User.ToDtoForViewer(presenceTracker, currentUserId, currentMembership is not null),
x.Role,
x.IsOwner,
BuildPermissions(x, chat.Type)))
.ToList();
var counterpart = chat.Type == ChatType.Direct
? chat.Members.Select(x => x.User).FirstOrDefault(x => x.Id != currentUserId)
: null;
var title = chat.Type == ChatType.Direct
? counterpart?.DisplayName ?? (string.IsNullOrWhiteSpace(chat.Title) ? "Dialog" : chat.Title)
: string.IsNullOrWhiteSpace(chat.Title) ? "New Group" : chat.Title;
var sourceMessages = messagesOverride ?? chat.Messages.OrderBy(x => x.SentAt).ToList();
foreach (var message in sourceMessages)
{
message.Chat = chat;
}
var messages = sourceMessages
.OrderBy(x => x.SentAt)
.Select(x => x.ToDto(presenceTracker, currentUserId))
.ToList();
var pinnedMessages = (pinnedMessagesOverride ?? chat.Messages
.Where(x => x.PinnedAt is not null && x.DeletedAt is null)
.OrderByDescending(x => x.PinnedAt)
.Take(5)
.ToList())
.Select(message =>
{
message.Chat = chat;
return message.ToDto(presenceTracker, currentUserId);
})
.ToList();
return new ChatDetailsDto(
chat.Id,
title,
chat.Type,
participants,
canSendMessages,
messages,
firstUnreadMessageId,
hasMoreMessages,
pinnedMessages,
permissions,
members,
chat.HasProtectedContent,
chat.DefaultMessageTtlSeconds,
channelStats.SubscriberCount,
channelStats.AdminCount,
currentMembership is not null,
chat.Type == ChatType.Channel && currentMembership is null && !string.IsNullOrWhiteSpace(chat.PublicUsername),
permissions.CanManageMembers,
chat.Type == ChatType.Channel && canSendMessages,
chat.PublicUsername,
chat.LinkedDiscussionChatId,
chat.ChannelSignaturesEnabled,
chat.Description,
CanDeleteForEveryone(chat, currentMembership));
}
public static ModerationLogDto ToDto(this ModerationLogEntry entry, PresenceTracker presenceTracker) =>
new(
entry.Id,
entry.ChatId,
entry.ActorUserId,
string.IsNullOrWhiteSpace(entry.ActorUser.DisplayName)
? entry.ActorUser.Username
: entry.ActorUser.DisplayName,
entry.TargetType,
entry.TargetId,
entry.Action,
entry.Reason,
entry.CreatedAt);
public static StoryDto ToDto(
this Story story,
PresenceTracker presenceTracker,
Guid currentUserId,
bool sharesChatWithViewer = false) =>
new(
story.Id,
story.AuthorUser.ToDtoForViewer(
presenceTracker,
currentUserId,
story.AuthorUserId == currentUserId || sharesChatWithViewer),
story.DeletedAt is null ? story.Text : string.Empty,
story.DeletedAt is null
? story.Attachments
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.OriginalFileName)
.Select(x => x.ToDto(story.Id))
.ToList()
: [],
story.Visibility,
story.HasProtectedContent,
story.CreatedAt,
story.ExpiresAt,
story.DeletedAt,
story.Views.Count,
story.Views.Any(x => x.ViewerUserId == currentUserId));
public static AttachmentDto ToDto(this StoryAttachment attachment, Guid storyId) =>
new(
attachment.Id,
attachment.OriginalFileName,
attachment.ContentType,
attachment.FileSizeBytes,
$"/api/stories/{storyId}/attachments/{attachment.Id}/content",
AttachmentKind.File,
attachment.SortOrder);
public static ChatPermissionsDto BuildPermissions(ChatMember? membership, ChatType chatType)
{
if (membership is null)
{
return new ChatPermissionsDto(false, false, false, false, false);
}
var isManager = membership.IsOwner || membership.Role is ChatMemberRole.Owner or ChatMemberRole.Admin;
var canPost = chatType == ChatType.Direct || isManager || membership.CanPostMessages;
return new ChatPermissionsDto(
CanPostMessages: canPost,
CanPinMessages: isManager || membership.CanPinMessages,
CanDeleteMessages: isManager || membership.CanDeleteMessages,
CanInviteUsers: isManager || membership.CanInviteUsers,
CanManageMembers: isManager);
}
private static IReadOnlyList<ChatMember> BuildVisibleMembers(Chat chat, ChatMember? currentMembership)
{
if (chat.Type != ChatType.Channel)
{
return chat.Members.ToList();
}
if (currentMembership is not null && BuildPermissions(currentMembership, chat.Type).CanManageMembers)
{
return chat.Members.ToList();
}
if (currentMembership is null)
{
return chat.Members
.Where(IsChannelManager)
.ToList();
}
return chat.Members
.Where(member => member.UserId == currentMembership.UserId || IsChannelManager(member))
.ToList();
}
private static ChannelStats BuildChannelStats(Chat chat)
{
if (chat.Type != ChatType.Channel)
{
var memberCount = chat.Members.Count;
return new ChannelStats(memberCount, chat.Members.Count(IsChannelManager));
}
return new ChannelStats(
chat.Members.Count,
chat.Members.Count(IsChannelManager));
}
private static bool IsChannelManager(ChatMember member) =>
member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin;
private static bool CanDeleteForEveryone(Chat chat, ChatMember? membership) =>
membership is not null &&
(chat.Type == ChatType.Direct ||
membership.IsOwner ||
membership.Role == ChatMemberRole.Owner);
private static string FormatSubscribers(int count) =>
$"{count} {FormatRussianPlural(count, "подписчик", "подписчика", "подписчиков")}";
private static string FormatRussianPlural(int count, string one, string few, string many)
{
var value = Math.Abs(count) % 100;
var lastDigit = value % 10;
if (value is >= 11 and <= 14)
{
return many;
}
return lastDigit switch
{
1 => one,
>= 2 and <= 4 => few,
_ => many
};
}
private static UserSummaryDto ResolveMessageSenderDto(Message message, PresenceTracker presenceTracker, Guid currentUserId)
{
if (message.Chat is { Type: ChatType.Channel } chat &&
message.PostedAsChannel &&
!CanInspectChannelSender(chat, currentUserId))
{
return BuildChannelIdentityDto(chat);
}
return message.Sender.ToDtoForViewer(
presenceTracker,
currentUserId,
SharesChatWithCurrentUser(message.Chat, currentUserId));
}
private static bool SharesChatWithCurrentUser(Chat? chat, Guid currentUserId) =>
chat?.Members.Any(member => member.UserId == currentUserId) == true;
private static bool CanViewFieldByPrivacy(
Guid viewerUserId,
string? visibilityValue,
string? alwaysAllowUserIds,
string? neverAllowUserIds,
string fallback,
bool sharesChatWithViewer)
{
var neverAllow = DeserializeUserIdList(neverAllowUserIds).ToHashSet();
if (neverAllow.Contains(viewerUserId))
{
return false;
}
var alwaysAllow = DeserializeUserIdList(alwaysAllowUserIds).ToHashSet();
if (alwaysAllow.Contains(viewerUserId))
{
return true;
}
return NormalizePrivacyVisibility(visibilityValue, fallback) switch
{
"everyone" => true,
"contacts" => sharesChatWithViewer,
_ => false
};
}
private static string NormalizePrivacyVisibility(string? value, string fallback)
{
var normalized = value?.Trim().ToLowerInvariant();
return normalized is "everyone" or "contacts" or "nobody" ? normalized : fallback;
}
private static IReadOnlyList<Guid> DeserializeUserIdList(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => Guid.TryParse(x, out var parsed) ? parsed : Guid.Empty)
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
}
private static bool CanInspectChannelSender(Chat chat, Guid currentUserId)
{
var membership = chat.Members.FirstOrDefault(member => member.UserId == currentUserId);
return membership is not null && BuildPermissions(membership, chat.Type).CanManageMembers;
}
private static UserSummaryDto BuildChannelIdentityDto(Chat chat) =>
new(
chat.Id,
chat.PublicUsername ?? $"channel-{chat.Id:N}",
string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!,
false,
null,
null,
null,
null);
private static string ResolveDisplayAuthorName(Message message)
{
if (message.Chat?.Type == ChatType.Channel && message.PostedAsChannel)
{
if (!string.IsNullOrWhiteSpace(message.AuthorSignature))
{
return message.AuthorSignature!;
}
return string.IsNullOrWhiteSpace(message.Chat.Title) ? "Channel" : message.Chat.Title!;
}
return string.IsNullOrWhiteSpace(message.Sender.DisplayName)
? message.Sender.Username
: message.Sender.DisplayName;
}
private static string ResolveReplyAuthorName(Message replyToMessage, Chat? currentChat)
{
if (currentChat?.Type == ChatType.Channel && replyToMessage.PostedAsChannel)
{
if (!string.IsNullOrWhiteSpace(replyToMessage.AuthorSignature))
{
return replyToMessage.AuthorSignature!;
}
return string.IsNullOrWhiteSpace(currentChat.Title) ? "Channel" : currentChat.Title!;
}
return string.IsNullOrWhiteSpace(replyToMessage.Sender.DisplayName)
? replyToMessage.Sender.Username
: replyToMessage.Sender.DisplayName;
}
public static string BuildPushTitle(this Chat chat, Message message) =>
chat.Type switch
{
ChatType.Direct => message.Sender.DisplayName,
ChatType.Channel when message.PostedAsChannel => string.IsNullOrWhiteSpace(chat.Title) ? "Channel" : chat.Title!,
_ => string.IsNullOrWhiteSpace(chat.Title) ? message.Sender.DisplayName : chat.Title!
};
public static string BuildPushBody(this Message message)
{
if (message.DeletedAt is not null)
{
return "Message removed";
}
if (!string.IsNullOrWhiteSpace(message.Text))
{
return message.Attachments.Count == 0
? message.Text
: $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})";
}
return BuildAttachmentPreview(message.Attachments);
}
private static string BuildMessagePreview(Message lastMessage)
{
var isChannelPost = lastMessage.Chat?.Type == ChatType.Channel && lastMessage.PostedAsChannel;
if (lastMessage.DeletedAt is not null)
{
return isChannelPost
? "Публикация удалена"
: $"{ResolveDisplayAuthorName(lastMessage)}: message removed";
}
if (string.IsNullOrWhiteSpace(lastMessage.Text))
{
var attachmentPreview = BuildAttachmentPreview(lastMessage.Attachments);
return isChannelPost
? attachmentPreview
: $"{ResolveDisplayAuthorName(lastMessage)}: {attachmentPreview}";
}
var textPreview = lastMessage.Attachments.Count == 0
? lastMessage.Text
: $"{lastMessage.Text} ({BuildAttachmentPreview(lastMessage.Attachments)})";
if (lastMessage.StoryReplyStoryId is not null)
{
textPreview = $"Ответ на сторис: {textPreview}";
}
return isChannelPost
? textPreview
: $"{ResolveDisplayAuthorName(lastMessage)}: {textPreview}";
}
private static MessageReplyDto? BuildReplyDto(Message? replyToMessage, Chat? currentChat)
{
if (replyToMessage is null)
{
return null;
}
return new MessageReplyDto(
replyToMessage.Id,
replyToMessage.SenderId,
ResolveReplyAuthorName(replyToMessage, currentChat),
BuildReplyPreviewText(replyToMessage));
}
private static StoryReplyDto? BuildStoryReplyDto(Message message)
{
if (message.StoryReplyStoryId is null || message.StoryReplyAuthorUserId is null)
{
return null;
}
return new StoryReplyDto(
message.StoryReplyStoryId.Value,
message.StoryReplyAuthorUserId.Value,
string.IsNullOrWhiteSpace(message.StoryReplyPreviewText) ? "Story" : message.StoryReplyPreviewText!,
message.StoryReplyAttachmentKind);
}
private static IReadOnlyList<MessageReactionDto> BuildReactions(Message message, Guid currentUserId) =>
message.Reactions
.Where(reaction => !string.IsNullOrWhiteSpace(reaction.Emoji))
.GroupBy(reaction => reaction.Emoji)
.Select(group => new MessageReactionDto(
group.Key,
group.Count(),
group.Any(reaction => reaction.UserId == currentUserId)))
.OrderByDescending(reaction => reaction.Count)
.ThenBy(reaction => reaction.Emoji, StringComparer.Ordinal)
.ToList();
private static string BuildAttachmentPreview(ICollection<MessageAttachment> attachments)
{
if (attachments.Count == 0)
{
return string.Empty;
}
if (attachments.Count == 1)
{
var attachment = attachments.First();
if (ResolveAttachmentKind(attachment) == AttachmentKind.VoiceNote)
{
return "голосовое сообщение";
}
if (IsImageAttachment(attachment.ContentType, attachment.OriginalFileName))
{
return "фотография";
}
return $"файл: {attachment.OriginalFileName}";
}
if (attachments.All(attachment => IsImageAttachment(attachment.ContentType, attachment.OriginalFileName)))
{
return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "фотография", "фотографии", "фотографий")}";
}
return $"{attachments.Count} {FormatRussianPlural(attachments.Count, "файл", "файла", "файлов")}";
}
private static string BuildReplyPreviewText(Message message)
{
if (message.DeletedAt is not null)
{
return "Сообщение удалено";
}
if (!string.IsNullOrWhiteSpace(message.Text))
{
return message.Attachments.Count == 0
? message.Text
: $"{message.Text} ({BuildAttachmentPreview(message.Attachments)})";
}
return BuildAttachmentPreview(message.Attachments);
}
private static AttachmentKind ResolveAttachmentKind(MessageAttachment attachment) =>
attachment.Kind == AttachmentKind.VoiceNote || IsVoiceLikeAttachment(attachment)
? AttachmentKind.VoiceNote
: AttachmentKind.File;
private static bool IsVoiceLikeAttachment(MessageAttachment attachment)
{
var contentType = attachment.ContentType
.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault()
?.Trim()
.ToLowerInvariant();
if (contentType is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg")
{
return true;
}
var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
var lastToken = attachment.OriginalFileName
.Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault()
?.ToLowerInvariant();
extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
}
return extension is ".ogg" or ".oga" or ".opus";
}
private static MessageDeliveryState ResolveDeliveryState(Message message, Guid currentUserId)
{
if (message.SenderId != currentUserId)
{
return MessageDeliveryState.Sent;
}
var recipients = (message.Chat?.Members ?? [])
.Where(member => member.UserId != currentUserId)
.ToList();
if (recipients.Count == 0)
{
return MessageDeliveryState.Sent;
}
if (recipients.All(member => member.LastReadAt is not null && member.LastReadAt.Value >= message.SentAt))
{
return MessageDeliveryState.Read;
}
if (recipients.All(member => member.LastDeliveredAt is not null && member.LastDeliveredAt.Value >= message.SentAt))
{
return MessageDeliveryState.Delivered;
}
return MessageDeliveryState.Sent;
}
private static bool IsImageAttachment(string? contentType, string? fileName)
{
if (!string.IsNullOrWhiteSpace(contentType) &&
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var extension = Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant();
return extension is ".jpg" or ".jpeg" or ".png" or ".webp" or ".gif";
}
private sealed record ChannelStats(int SubscriberCount, int AdminCount);
private static string? ResolveAvatarPath(User user)
{
if (string.IsNullOrWhiteSpace(user.AvatarStoragePath))
{
return null;
}
var fileName = Path.GetFileName(user.AvatarStoragePath);
return string.IsNullOrWhiteSpace(fileName)
? null
: $"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(fileName)}";
}
}