809 lines
29 KiB
C#
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)}";
|
|
}
|
|
}
|