From 08e485f9b437bf8bda930923598f2fc8ce059cfc Mon Sep 17 00:00:00 2001 From: cheykrym Date: Thu, 11 Dec 2025 23:47:42 +0300 Subject: [PATCH] patch bubble --- yobble/Views/Chat/PrivateChatView.swift | 78 +++++++++++++++++++------ 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index bfe439f..3ae0bd7 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -162,13 +162,13 @@ struct PrivateChatView: View { .scaleEffect(x: 1, y: -1, anchor: .center) } - ForEach(viewModel.messages.reversed()) { message in - messageRow(for: message) - .id(message.id) + ForEach(decoratedMessages.reversed()) { decoratedMessage in + messageRow(for: decoratedMessage) + .id(decoratedMessage.id) .scaleEffect(x: 1, y: -1, anchor: .center) .onAppear { guard hasPositionedToBottom else { return } - viewModel.loadMoreIfNeeded(for: message) + viewModel.loadMoreIfNeeded(for: decoratedMessage.message) } } @@ -236,19 +236,28 @@ struct PrivateChatView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func messageRow(for message: MessageItem) -> some View { + private func messageRow(for decoratedMessage: DecoratedMessage) -> some View { + let message = decoratedMessage.message let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false return HStack(alignment: .bottom, spacing: 8) { if isCurrentUser { Spacer(minLength: 32) } - messageBubble(for: message, isCurrentUser: isCurrentUser) + messageBubble( + for: message, + decorations: decoratedMessage, + isCurrentUser: isCurrentUser + ) if !isCurrentUser { Spacer(minLength: 32) } } .padding(.horizontal, 8) } - private func messageBubble(for message: MessageItem, isCurrentUser: Bool) -> some View { + private func messageBubble( + for message: MessageItem, + decorations: DecoratedMessage, + isCurrentUser: Bool + ) -> some View { let timeText = timestamp(for: message) let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground) @@ -268,7 +277,10 @@ struct PrivateChatView: View { .padding(.vertical, 10) .padding(.horizontal, 12) .background( - MessageBubbleShape() + MessageBubbleShape( + showHorns: decorations.showHorns, + showLegs: decorations.showLegs + ) .fill(bubbleColor) ) .frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading) @@ -306,6 +318,31 @@ struct PrivateChatView: View { return content } + private var decoratedMessages: [DecoratedMessage] { + let messages = viewModel.messages + guard !messages.isEmpty else { return [] } + + var result: [DecoratedMessage] = [] + result.reserveCapacity(messages.count) + + for (index, message) in messages.enumerated() { + let previousSender = index > 0 ? messages[index - 1].senderId : nil + let nextSender = index < messages.count - 1 ? messages[index + 1].senderId : nil + + let showHorns = previousSender != message.senderId + let showLegs = nextSender != message.senderId + + result.append( + DecoratedMessage( + message: message, + showHorns: showHorns, + showLegs: showLegs + ) + ) + } + return result + } + private func errorBanner(message: String) -> some View { HStack { Image(systemName: "exclamationmark.triangle.fill") @@ -769,6 +806,15 @@ private var headerPlaceholderAvatar: some View { } +/// Helper model that stores a message alongside horn/leg flags for grouping sequences. +private struct DecoratedMessage: Identifiable { + let message: MessageItem + let showHorns: Bool + let showLegs: Bool + + var id: MessageItem.ID { message.id } +} + /// Decorative bubble with two horns on top and two legs on bottom to mimic cartoon-style speech clouds. private struct MessageBubbleShape: Shape { var cornerRadius: CGFloat = 18 @@ -778,6 +824,8 @@ private struct MessageBubbleShape: Shape { var legHeight: CGFloat = 6 var legWidth: CGFloat = 18 var legSpacing: CGFloat = 14 + var showHorns: Bool = true + var showLegs: Bool = true func path(in rect: CGRect) -> Path { var path = Path() @@ -791,17 +839,11 @@ private struct MessageBubbleShape: Shape { let horizontalSpan = max(0, rect.width - 2 * radius) - let hornsEnabled = horizontalSpan > 0.5 - let legsEnabled = horizontalSpan > 0.5 + let hornsEnabled = showHorns && horizontalSpan > 0.5 + let legsEnabled = showLegs && horizontalSpan > 0.5 - func clampedFeatureWidth(targetWidth: CGFloat) -> CGFloat { - guard horizontalSpan > 0 else { return 0 } - let preferredWidth = max(horizontalSpan * 0.25, targetWidth) - return min(preferredWidth, min(targetWidth, horizontalSpan / 2)) - } - - let effectiveHornWidth = hornsEnabled ? clampedFeatureWidth(targetWidth: hornWidth) : 0 - let effectiveLegWidth = legsEnabled ? clampedFeatureWidth(targetWidth: legWidth) : 0 + let effectiveHornWidth = hornsEnabled ? min(hornWidth, horizontalSpan / 2) : 0 + let effectiveLegWidth = legsEnabled ? min(legWidth, horizontalSpan / 2) : 0 let hornSpacingCandidate = max(horizontalSpan - effectiveHornWidth * 2, 0) / 2 let legSpacingCandidate = max(horizontalSpan - effectiveLegWidth * 2, 0) / 2