diff --git a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs index 02a0523..b4633ad 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs @@ -32,7 +32,8 @@ public record MessageDetailDto( bool? PollIsMultipleChoice = null, bool? PollIsAnonymous = null, bool? PollIsClosed = null, - List? UserVotedOptionIds = null + List? UserVotedOptionIds = null, + bool IsDeletedForUser = false ); public record ReplyToMessageDto( diff --git a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs index d90fce4..95ded75 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Delete/DeleteMessagesCommand.cs @@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List(), replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null ) : null, - message is TextMessage tm ? tm.Quote : null, + (message as TextMessage)?.Quote, message.IsEdited, message.IsDeleted, message.CreatedAt, @@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List(), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), new List(), // ReadBy not implemented in this detailed view yet - reactions?.Select(r => { + reactions?.Select(r => + { senders.TryGetValue(r.UserId, out var ru); return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null); }).ToList() ?? new List(), (message as CallMessage)?.CallType, (message as CallMessage)?.CallStatus, (message as CallMessage)?.Duration, - (message as PollMessage)?.Options.Select(o => { + (message as PollMessage)?.Options.Select(o => + { var pm = (PollMessage)message; var voters = pm.IsAnonymous == false ? pm.Votes .Where(v => v.OptionId == o.Id) - .Select(v => { + .Select(v => + { senders.TryGetValue(v.UserId, out var vu); return vu != null ? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar) @@ -174,12 +178,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); - }).ToList(), + return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); + }).ToList(), (message as PollMessage)?.IsMultipleChoice, (message as PollMessage)?.IsAnonymous, (message as PollMessage)?.IsClosed, - (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList() + (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(), + message.IsDeletedForUser(request.UserId) )); } diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 69722ba..c7c9f47 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -6,7 +6,7 @@ export interface UserBasic { displayName: string; avatarUrl: string | null; // Fallbacks for compatibility - username?: string; + username?: string; avatar?: string | null; } @@ -51,11 +51,11 @@ export interface Reaction { id: string; emoji: string; userId: string; - user: { - id: string; - username: string; + user: { + id: string; + username: string; userName?: string; - displayName: string; + displayName: string; avatar?: string | null; avatarUrl?: string | null; }; @@ -84,6 +84,7 @@ export interface Message { storyMediaType?: string | null; isEdited: boolean; isDeleted: boolean; + isDeletedForUser?: boolean; scheduledAt?: string | null; createdAt: string; updatedAt?: string; @@ -95,9 +96,9 @@ export interface Message { isDeleted?: boolean; quote?: string | null; media?: MediaItem[]; - sender: { - id: string; - username: string; + sender: { + id: string; + username: string; userName?: string; displayName: string; avatar?: string | null; diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 242ff5f..97ae92d 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -290,64 +290,64 @@ function MessageBubble({ }; }, [showContext]); - if (message.isDeleted) { + if (message.isDeleted || message.isDeletedForUser) { return null; } if (message.type === 'call') { const isMissed = message.callStatus === 'missed' || message.callStatus === 'declined' || message.callStatus === 'cancelled'; - const statusText = isMissed - ? (message.callStatus === 'missed' ? t('missedCall') : message.callStatus === 'declined' ? t('declinedCall') : t('cancelledCall')) - : t('completedCall'); - + const statusText = isMissed + ? (message.callStatus === 'missed' ? t('missedCall') : message.callStatus === 'declined' ? t('declinedCall') : t('cancelledCall')) + : t('completedCall'); + const StatusIcon = isMissed ? PhoneMissed : (isMine ? PhoneOutgoing : PhoneIncoming); const CallIcon = message.callType === 'video' ? Video : StatusIcon; - + return (
-
- -
+ +
- -
+ +
-
-

- {message.callType === 'video' ? t('videoCall') : t('audioCall')} -

-
- - {statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''} - +
+

+ {message.callType === 'video' ? t('videoCall') : t('audioCall')} +

+
+ + {statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''} + +
+
+ +
+
+ {isPinned && } + {timeStr} +
+ {isMine && ( +
+ {isRead ? : }
-
+ )} +
-
-
- {isPinned && } - {timeStr} -
- {isMine && ( -
- {isRead ? : } -
- )} -
- -
+
); } const media = message.media || []; - + const isMediaGif = (m: MediaItem) => { if (m.type === 'gif') return true; if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true; @@ -486,314 +486,234 @@ function MessageBubble({ const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasVoice || hasAudio || hasFile || !!message.storyId; return ( -
+
- {/* Reply */} - {message.replyTo && ( -
{ - e.stopPropagation(); - const el = document.getElementById(`msg-${message.replyToId}`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 5000); - } - }} - > -

- {message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''} -

-
- {message.replyTo.isDeleted ? ( -

{t('messageDeleted')}

- ) : ( - <> - {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => { - const m = message.replyTo.media[0]; - const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'); - return ( -
- {m.type === 'image' ? ( - isMp4 ? ( -
- ); - })()} -

- {message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => { - const m = message.replyTo.media[0]; - if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF'; - if (m.type === 'image') return t('photo'); - if (m.type === 'video') return t('video'); - if (m.type === 'voice') return t('voice'); - return t('media'); - })() : '')} + {/* Reply */} + {message.replyTo && ( +

{ + e.stopPropagation(); + const el = document.getElementById(`msg-${message.replyToId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('highlight-message'); + setTimeout(() => el.classList.remove('highlight-message'), 5000); + } + }} + > +

+ {message.replyTo.sender?.displayName || message.replyTo.sender?.userName || message.replyTo.sender?.username || ''}

- - )} -
-
- )} - - {/* Story Reply Quote */} - {message.storyId && ( -
-

- {t('story')} -

-
- {message.storyMediaUrl && ( -
- {message.storyMediaType === 'video' ? ( -
-
- ) : message.storyMediaType === 'image' ? ( - - ) : ( -
- )} +
+ {message.replyTo.isDeleted ? ( +

{t('messageDeleted')}

+ ) : ( + <> + {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => { + const m = message.replyTo.media[0]; + const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'); + return ( +
+ {m.type === 'image' ? ( + isMp4 ? ( +
+ ); + })()} +

+ {message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => { + const m = message.replyTo.media[0]; + if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF'; + if (m.type === 'image') return t('photo'); + if (m.type === 'video') return t('video'); + if (m.type === 'voice') return t('voice'); + return t('media'); + })() : '')} +

+ + )} +
)} -

- {message.quote} -

-
-
- )} - {/* Рендер пересланного сообщения */} - {message.forwardedFrom && ( -
onViewProfile?.(message.forwardedFromId!)} - > -
- {(t('forwardedFrom' as any) === 'forwardedFrom' ? 'Переслано от' : t('forwardedFrom' as any))} {message.forwardedFrom.displayName || message.forwardedFrom.userName || message.forwardedFrom.username || ''} -
-
- )} - {/* Изображения и Видео (Галерея) */} - {(hasImage || hasVideo) && (() => { - const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif(m)); - const isSingleGif = galleryMedia.length === 1 && isMediaGif(galleryMedia[0]); - const hasReactions = Object.keys(reactionGroups).length > 0; + {/* Story Reply Quote */} + {message.storyId && ( +
+

+ {t('story')} +

+
+ {message.storyMediaUrl && ( +
+ {message.storyMediaType === 'video' ? ( +
+
+ ) : message.storyMediaType === 'image' ? ( + + ) : ( +
+ )} +
+ )} +

+ {message.quote} +

+
+
+ )} + {/* Рендер пересланного сообщения */} + {message.forwardedFrom && ( +
onViewProfile?.(message.forwardedFromId!)} + > +
+ {(t('forwardedFrom' as any) === 'forwardedFrom' ? 'Переслано от' : t('forwardedFrom' as any))} {message.forwardedFrom.displayName || message.forwardedFrom.userName || message.forwardedFrom.username || ''} +
+
+ )} - return ( -
{ + const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif(m)); + const isSingleGif = galleryMedia.length === 1 && isMediaGif(galleryMedia[0]); + const hasReactions = Object.keys(reactionGroups).length > 0; + + return ( +
-
1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${ - galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6' - }`}> - {galleryMedia.map((m, idx) => { - const gif = isMediaGif(m); - - let cellClass = ''; - const count = galleryMedia.length; - - if (count === 1) { - cellClass = isSingleGif ? 'max-h-[260px] aspect-auto' : 'max-h-[350px] sm:max-h-[450px] md:max-h-[500px] h-auto aspect-auto'; - } else if (count === 2) { - cellClass = 'col-span-3 aspect-square'; - } else if (count === 3) { - cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : 'col-span-3 aspect-square'; - } else if (count === 4) { - cellClass = 'col-span-3 aspect-square'; - } else if (count === 5) { - cellClass = idx < 2 ? 'col-span-3 aspect-square' : 'col-span-2 aspect-square'; - } else if (count === 6) { - cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : idx < 3 ? 'col-span-3 aspect-[4/3]' : 'col-span-2 aspect-square'; - } else { - // 7+ - cellClass = 'col-span-2 aspect-square'; - } +
1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6' + }`}> + {galleryMedia.map((m, idx) => { + const gif = isMediaGif(m); - return ( -
setLightboxData({ index: idx })} - > - {gif ? ( - (m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? ( - - ) : ( -
+ + {!message.content && ( +
+ + {isPinned && } + {timeStr} + {isMine && !message.scheduledAt && ( + + {isRead ? 'done_all' : 'done'} + + )} +
- ); - })} -
- - {!message.content && ( -
- - {isPinned && } - {timeStr} - {isMine && !message.scheduledAt && ( - - {isRead ? 'done_all' : 'done'} - )} - -
- )} -
- ); - })()} - - {/* Голосовое - Optimized Kinetic Layout */} - {hasVoice && ( -
-