patch bubble

This commit is contained in:
cheykrym 2025-12-11 23:47:42 +03:00
parent 1903e86a02
commit 08e485f9b4

View File

@ -162,13 +162,13 @@ struct PrivateChatView: View {
.scaleEffect(x: 1, y: -1, anchor: .center) .scaleEffect(x: 1, y: -1, anchor: .center)
} }
ForEach(viewModel.messages.reversed()) { message in ForEach(decoratedMessages.reversed()) { decoratedMessage in
messageRow(for: message) messageRow(for: decoratedMessage)
.id(message.id) .id(decoratedMessage.id)
.scaleEffect(x: 1, y: -1, anchor: .center) .scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear { .onAppear {
guard hasPositionedToBottom else { return } 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) .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 let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack(alignment: .bottom, spacing: 8) { return HStack(alignment: .bottom, spacing: 8) {
if isCurrentUser { Spacer(minLength: 32) } if isCurrentUser { Spacer(minLength: 32) }
messageBubble(for: message, isCurrentUser: isCurrentUser) messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
if !isCurrentUser { Spacer(minLength: 32) } if !isCurrentUser { Spacer(minLength: 32) }
} }
.padding(.horizontal, 8) .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 timeText = timestamp(for: message)
let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground) let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)
@ -268,7 +277,10 @@ struct PrivateChatView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.background( .background(
MessageBubbleShape() MessageBubbleShape(
showHorns: decorations.showHorns,
showLegs: decorations.showLegs
)
.fill(bubbleColor) .fill(bubbleColor)
) )
.frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading) .frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading)
@ -306,6 +318,31 @@ struct PrivateChatView: View {
return content 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 { private func errorBanner(message: String) -> some View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle.fill") 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. /// Decorative bubble with two horns on top and two legs on bottom to mimic cartoon-style speech clouds.
private struct MessageBubbleShape: Shape { private struct MessageBubbleShape: Shape {
var cornerRadius: CGFloat = 18 var cornerRadius: CGFloat = 18
@ -778,6 +824,8 @@ private struct MessageBubbleShape: Shape {
var legHeight: CGFloat = 6 var legHeight: CGFloat = 6
var legWidth: CGFloat = 18 var legWidth: CGFloat = 18
var legSpacing: CGFloat = 14 var legSpacing: CGFloat = 14
var showHorns: Bool = true
var showLegs: Bool = true
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
var path = Path() var path = Path()
@ -791,17 +839,11 @@ private struct MessageBubbleShape: Shape {
let horizontalSpan = max(0, rect.width - 2 * radius) let horizontalSpan = max(0, rect.width - 2 * radius)
let hornsEnabled = horizontalSpan > 0.5 let hornsEnabled = showHorns && horizontalSpan > 0.5
let legsEnabled = horizontalSpan > 0.5 let legsEnabled = showLegs && horizontalSpan > 0.5
func clampedFeatureWidth(targetWidth: CGFloat) -> CGFloat { let effectiveHornWidth = hornsEnabled ? min(hornWidth, horizontalSpan / 2) : 0
guard horizontalSpan > 0 else { return 0 } let effectiveLegWidth = legsEnabled ? min(legWidth, horizontalSpan / 2) : 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 hornSpacingCandidate = max(horizontalSpan - effectiveHornWidth * 2, 0) / 2 let hornSpacingCandidate = max(horizontalSpan - effectiveHornWidth * 2, 0) / 2
let legSpacingCandidate = max(horizontalSpan - effectiveLegWidth * 2, 0) / 2 let legSpacingCandidate = max(horizontalSpan - effectiveLegWidth * 2, 0) / 2