import SwiftUI #if canImport(UIKit) import UIKit #endif struct PrivateChatView: View { let chat: PrivateChatListItem let currentUserId: String? private let bottomAnchorId = "PrivateChatBottomAnchor" private let headerAvatarSize: CGFloat = 36 let lineLimitInChat = 6 @StateObject private var viewModel: PrivateChatViewModel @State private var hasPositionedToBottom: Bool = false @State private var scrollToBottomTrigger: UUID = .init() @State private var isBottomAnchorVisible: Bool = true @State private var draftText: String = "" @State private var inputTab: ComposerTab = .chat @State private var isVideoPreferred: Bool = false @State private var legacyComposerHeight: CGFloat = 40 @State private var isProfilePresented: Bool = false @FocusState private var isComposerFocused: Bool @EnvironmentObject private var messageCenter: IncomingMessageCenter @Environment(\.dismiss) private var dismiss @State private var previousStandardAppearance: UINavigationBarAppearance? @State private var previousScrollEdgeAppearance: UINavigationBarAppearance? @State private var previousCompactAppearance: UINavigationBarAppearance? init(chat: PrivateChatListItem, currentUserId: String?) { self.chat = chat self.currentUserId = currentUserId _viewModel = StateObject(wrappedValue: PrivateChatViewModel(chatId: chat.chatId, currentUserId: currentUserId)) } var body: some View { ZStack { ScrollViewReader { proxy in ZStack(alignment: .bottomTrailing) { content .onChange(of: viewModel.messages.count) { _ in if isBottomAnchorVisible { scrollToBottom(proxy: proxy) } } .onChange(of: scrollToBottomTrigger) { _ in scrollToBottom(proxy: proxy) } if !isBottomAnchorVisible && !viewModel.isInitialLoading { scrollToBottomButton(proxy: proxy) .padding(.trailing, 12) .padding(.bottom, 4) } } } NavigationLink( destination: MessageProfileView(chat: chat, currentUserId: currentUserId), isActive: $isProfilePresented ) { EmptyView() } .hidden() } .navigationTitle(toolbarTitle) .navigationBarTitleDisplayMode(.inline) // .navigationBarBackButtonHidden(true) // .toolbar { // ToolbarItem(placement: .principal) { // chatToolbarContent // } // } .toolbar { // ToolbarItem(placement: .navigationBarLeading) { // Button(action: { dismiss() }) { // Image(systemName: "chevron.left") // .font(.system(size: 17, weight: .semibold)) // .foregroundColor(.accentColor) // } // } ToolbarItem(placement: .principal) { Button(action: openProfile) { nameStatusView } .buttonStyle(.plain) } ToolbarItem(placement: .navigationBarTrailing) { avatarButton } } .task { viewModel.loadInitialHistory() } .onAppear { messageCenter.activeChatId = chat.chatId previousStandardAppearance = UINavigationBar.appearance().standardAppearance previousScrollEdgeAppearance = UINavigationBar.appearance().scrollEdgeAppearance previousCompactAppearance = UINavigationBar.appearance().compactAppearance let appearance = UINavigationBarAppearance() appearance.configureWithDefaultBackground() // appearance.shadowColor = .clear UINavigationBar.appearance().standardAppearance = appearance UINavigationBar.appearance().scrollEdgeAppearance = appearance UINavigationBar.appearance().compactAppearance = appearance } .onChange(of: viewModel.isInitialLoading) { isLoading in if isLoading { hasPositionedToBottom = false } else if !viewModel.messages.isEmpty { scrollToBottomTrigger = .init() } } .safeAreaInset(edge: .bottom) { composer } .onDisappear { if messageCenter.activeChatId == chat.chatId { messageCenter.activeChatId = nil } if let standard = previousStandardAppearance { UINavigationBar.appearance().standardAppearance = standard } UINavigationBar.appearance().scrollEdgeAppearance = previousScrollEdgeAppearance UINavigationBar.appearance().compactAppearance = previousCompactAppearance } } @ViewBuilder private var content: some View { if viewModel.isInitialLoading && viewModel.messages.isEmpty { ProgressView(NSLocalizedString("Загрузка сообщений…", comment: "")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.errorMessage, viewModel.messages.isEmpty { errorView(message: error) } else { messagesList } } private var messagesList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { Color.clear .frame(height: 1) .id(bottomAnchorId) .onAppear { isBottomAnchorVisible = true } .onDisappear { isBottomAnchorVisible = false } if let message = viewModel.errorMessage, !message.isEmpty, !viewModel.messages.isEmpty { errorBanner(message: message) .scaleEffect(x: 1, y: -1, anchor: .center) } 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: decoratedMessage.message) } } if viewModel.isLoadingMore { loadingMoreView .scaleEffect(x: 1, y: -1, anchor: .center) } else if !viewModel.hasMore && !viewModel.messages.isEmpty { noMoreMessagesView .scaleEffect(x: 1, y: -1, anchor: .center) } else if viewModel.messages.isEmpty { emptyState .scaleEffect(x: 1, y: -1, anchor: .center) } } .padding(.vertical, 8) } .scaleEffect(x: 1, y: -1, anchor: .center) .simultaneousGesture( DragGesture().onChanged { value in guard value.translation.height > 0 else { return } isComposerFocused = false } ) } private var emptyState: some View { Text(NSLocalizedString("В чате пока нет сообщений.", comment: "")) .font(.footnote) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 32) } private var loadingMoreView: some View { HStack { ProgressView() Text(NSLocalizedString("Загружаем ранние сообщения…", comment: "")) .font(.caption) } .frame(maxWidth: .infinity) .padding(.vertical, 8) } private var noMoreMessagesView: some View { Text(NSLocalizedString("Больше сообщений нет", comment: "Chat history top reached")) .font(.caption) .foregroundColor(.secondary) .padding(.vertical, 16) .frame(maxWidth: .infinity) } private func errorView(message: String) -> some View { VStack(spacing: 12) { Text(message) .font(.body) .multilineTextAlignment(.center) .foregroundColor(.primary) Button(action: { viewModel.refresh() }) { Text(NSLocalizedString("Повторить", comment: "")) .font(.body) } } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } 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, decorations: decoratedMessage, isCurrentUser: isCurrentUser ) if !isCurrentUser { Spacer(minLength: 32) } } .padding(.horizontal, 8) } private func messageBubble( for message: MessageItem, decorations: DecoratedMessage, isCurrentUser: Bool ) -> some View { let timeText = timestamp(for: message) let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground) return VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 4) { Text(contentText(for: message)) .font(.body) .foregroundColor(isCurrentUser ? .white : .primary) .multilineTextAlignment(.leading) if !timeText.isEmpty { Text(timeText) .font(.caption2) .foregroundColor(isCurrentUser ? Color.white.opacity(0.85) : .secondary) } } .padding(.vertical, 10) .padding(.horizontal, 12) .background( MessageBubbleShape( showHorns: decorations.showHorns, showLegs: decorations.showLegs ) .fill(bubbleColor) ) .frame(maxWidth: messageBubbleMaxWidth, alignment: isCurrentUser ? .trailing : .leading) .fixedSize(horizontal: false, vertical: true) } private var messageBubbleMaxWidth: CGFloat { min(UIScreen.main.bounds.width * 0.72, 360) } private func senderName(for message: MessageItem) -> String { if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return full } if let custom = message.senderData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return custom } if let login = message.senderData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "@\(login)" } return message.senderId } private func timestamp(for message: MessageItem) -> String { guard let date = message.createdAt else { return "" } return Self.timeFormatter.string(from: date) } private func contentText(for message: MessageItem) -> String { guard let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty else { return NSLocalizedString("(без текста)", comment: "") } 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") .foregroundColor(.orange) Text(message) .font(.footnote) .foregroundColor(.primary) } .padding() .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .padding(.horizontal, 16) .padding(.top, 8) } private var composer: some View { VStack(spacing: 10) { HStack(alignment: .bottom, spacing: 4) { Button(action: { }) { // переключатель на стикеры Image(systemName: "paperclip") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.secondary) } // .buttonStyle(ComposerIconButtonStyle()) .frame(width: 36, height: 36) ZStack(alignment: .bottomTrailing) { Group { if #available(iOS 16.0, *) { TextField(inputTab.placeholder, text: $draftText, axis: .vertical) .lineLimit(1...lineLimitInChat) .focused($isComposerFocused) .submitLabel(.send) .disabled(currentUserId == nil) .onSubmit { sendCurrentMessage() } } else { LegacyMultilineTextView( text: $draftText, placeholder: inputTab.placeholder, isFocused: Binding( get: { isComposerFocused }, set: { isComposerFocused = $0 } ), isEnabled: currentUserId != nil, minHeight: 10, maxLines: lineLimitInChat, calculatedHeight: $legacyComposerHeight, onSubmit: sendCurrentMessage ) .frame(height: legacyComposerHeight) } } .padding(.top, 10) .padding(.leading, 12) .padding(.trailing, 44) .padding(.bottom, 10) .frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading) Button(action: { }) { // переключатель на стикеры Image(systemName: "face.smiling") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.secondary) } .padding(.trailing, 12) .padding(.bottom, 10) } .frame(minHeight: 40, alignment: .bottom) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .alignmentGuide(.bottom) { dimension in dimension[VerticalAlignment.bottom] - 2 } if !isSendAvailable { Button(action: { isVideoPreferred.toggle() }) { Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.secondary) } // .buttonStyle(ComposerIconButtonStyle()) .frame(width: 36, height: 36) } else { sendButton } } } .padding(.horizontal, 6) .padding(.top, 10) .padding(.bottom, 8) .background(.ultraThinMaterial) } private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { Button { scrollToBottom(proxy: proxy) } label: { Image(systemName: "arrow.down") .font(.system(size: 18, weight: .semibold)) .foregroundColor(.white) .frame(width: 44, height: 44) // .background(Color.accentColor) .background(Color(.secondarySystemBackground)) .clipShape(Circle()) // .overlay( // Circle().stroke(Color.white.opacity(0.35), lineWidth: 1) // ) } .buttonStyle(.plain) .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) } private var isSendDisabled: Bool { draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || currentUserId == nil } private var isSendAvailable: Bool { !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentUserId != nil } private var sendButton: some View { Button(action: sendCurrentMessage) { Image(systemName: "leaf.fill") .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.white.opacity(isSendDisabled ? 0.6 : 1)) .frame(width: 36, height: 36) .background(isSendDisabled ? Color.accentColor.opacity(0.4) : Color.accentColor) .clipShape(Circle()) } .disabled(isSendDisabled) .buttonStyle(.plain) } // private func composerToolbarButton(systemName: String, action: @escaping () -> Void) -> some View { // Button(action: action) { // Image(systemName: systemName) // .font(.system(size: 16, weight: .medium)) // } private func composerModeButton(_ tab: ComposerTab) -> some View { Button(action: { inputTab = tab }) { Text(tab.title) .font(.caption) .fontWeight(inputTab == tab ? .semibold : .regular) .padding(.vertical, 8) .padding(.horizontal, 14) .background( Group { if inputTab == tab { Color.accentColor.opacity(0.15) } else { Color(.secondarySystemBackground) } } ) .foregroundColor(inputTab == tab ? .accentColor : .primary) .clipShape(Capsule()) } .buttonStyle(.plain) } private func sendCurrentMessage() { let text = draftText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } draftText = "" scrollToBottomTrigger = .init() viewModel.sendMessage(text: text) { success in if success { hasPositionedToBottom = true } } } private func scrollToBottom(proxy: ScrollViewProxy) { hasPositionedToBottom = true let targetId = viewModel.messages.last?.id ?? bottomAnchorId DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(targetId, anchor: .top) } } } private struct ComposerIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .frame(width: 36, height: 36) .background(Color(.secondarySystemBackground)) .clipShape(Circle()) .overlay( Circle().stroke(Color.secondary.opacity(0.15)) ) .scaleEffect(configuration.isPressed ? 0.94 : 1) .opacity(configuration.isPressed ? 0.75 : 1) } } private enum ComposerTab: String { case chat case stickers var title: String { switch self { case .chat: return NSLocalizedString("Чат", comment: "") case .stickers: return NSLocalizedString("Стикеры", comment: "") } } var iconName: String { switch self { case .chat: return "text.bubble" case .stickers: return "face.smiling" } } var placeholder: String { switch self { case .chat: return NSLocalizedString("Сообщение", comment: "") case .stickers: return NSLocalizedString("Поиск стикеров", comment: "") } } } private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .none formatter.timeStyle = .short return formatter }() private var title: String { switch chat.chatType { case .self: return NSLocalizedString("Избранные сообщения", comment: "Saved messages title") case .privateChat, .unknown: if let custom = trimmed(chat.chatData?.customName) { return custom } if let full = trimmed(chat.chatData?.fullName) { return full } if let login = trimmed(chat.chatData?.login) { return "@\(login)" } return NSLocalizedString("Неизвестный пользователь", comment: "Unknown chat title") } } private var toolbarTitle: String { officialDisplayName ?? title } private var offlineStatusText: String { NSLocalizedString("Оффлайн", comment: "Offline status placeholder") } private var loginDisplay: String? { guard let login = trimmed(chat.chatData?.login) else { return nil } return "@\(login)" } private var isOfficial: Bool { chat.chatData?.isOfficial ?? false } private var officialDisplayName: String? { guard isOfficial else { return nil } if let customName = trimmed(chat.chatData?.customName) { return customName } if let name = trimmed(chat.chatData?.fullName) { return NSLocalizedString(name, comment: "Official chat name") } return loginDisplay } private var isDeletedUser: Bool { guard chat.chatType != .self else { return false } return trimmed(chat.chatData?.login) == nil } private var avatarBackgroundColor: Color { if isDeletedUser { return Color(.systemGray5) } return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15) } private var avatarTextColor: Color { if isDeletedUser { return Color.accentColor } return isOfficial ? Color.white : Color.accentColor } private var deletedUserSymbolName: String { "person.slash" } private var avatarUrl: URL? { guard let chatData = chat.chatData, let fileId = chatData.avatars?.current?.fileId else { return nil } let userId = chatData.userId return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)") } private var avatarInitial: String { if let name = trimmed(chat.chatData?.customName) ?? trimmed(chat.chatData?.fullName) { let components = name.split(separator: " ") let initials = components.prefix(2).compactMap { $0.first } if !initials.isEmpty { return initials.map { String($0) }.joined().uppercased() } } if let login = trimmed(chat.chatData?.login) { return String(login.prefix(1)).uppercased() } return "?" } private func trimmed(_ string: String?) -> String? { guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else { return nil } return string } private var chatToolbarContent: some View { HStack(spacing: 12) { backButton // Spacer(minLength: 0) nameStatusButton .frame(maxWidth: .infinity) // Spacer(minLength: 0) avatarButton } .frame(maxWidth: .infinity, minHeight: headerAvatarSize, alignment: .center) } @ViewBuilder private var nameStatusView: some View { VStack(spacing: 2) { if let officialName = officialDisplayName { HStack(spacing: 4) { nameText(officialName, weight: .semibold) Image(systemName: "checkmark.seal.fill") .foregroundColor(Color.accentColor) .font(.caption) } } else { nameText(title, weight: .semibold) } Text(offlineStatusText) .font(.caption) .foregroundColor(.secondary) } .multilineTextAlignment(.center) } @ViewBuilder private var avatarView: some View { if let url = avatarUrl, let fileId = chat.chatData?.avatars?.current?.fileId, let userId = currentUserId { CachedAvatarView(url: url, fileId: fileId, userId: userId) { headerPlaceholderAvatar } .aspectRatio(contentMode: .fill) .frame(width: headerAvatarSize, height: headerAvatarSize) .clipShape(Circle()) } else { headerPlaceholderAvatar } } private var nameStatusButton: some View { Button(action: openProfile) { nameStatusView } .buttonStyle(.plain) } private var avatarButton: some View { Button(action: openProfile) { avatarView } .buttonStyle(.plain) } private func nameText(_ text: String, weight: Font.Weight) -> some View { Group { if #available(iOS 16.0, *) { Text(text) .font(.headline) .fontWeight(weight) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) .strikethrough(isDeletedUser, color: Color.secondary) } else { Text(text) .font(.headline) .fontWeight(weight) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) } } } private var backButton: some View { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.accentColor) } .frame(width: headerAvatarSize, height: headerAvatarSize) .contentShape(Rectangle()) } private func openProfile() { isProfilePresented = true } private var headerPlaceholderAvatar: some View { Circle() .fill(avatarBackgroundColor) .frame(width: headerAvatarSize, height: headerAvatarSize) .overlay( Group { if isDeletedUser { Image(systemName: deletedUserSymbolName) .symbolRenderingMode(.hierarchical) .font(.system(size: headerAvatarSize * 0.45, weight: .semibold)) .foregroundColor(avatarTextColor) } else { Text(avatarInitial) .font(.system(size: headerAvatarSize * 0.5, weight: .semibold)) .foregroundColor(avatarTextColor) } } ) } } /// 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 var hornHeight: CGFloat = 10 var hornWidth: CGFloat = 16 var hornSpacing: CGFloat = 12 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() guard rect.width > 0, rect.height > 0 else { return path } let radius = min(cornerRadius, min(rect.width, rect.height) / 2) let innerTop = rect.minY + hornHeight let innerBottom = rect.maxY - legHeight let left = rect.minX let right = rect.maxX let horizontalSpan = max(0, rect.width - 2 * radius) let hornsEnabled = showHorns && horizontalSpan > 0.5 let legsEnabled = showLegs && horizontalSpan > 0.5 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 let effectiveHornSpacing = hornsEnabled ? min(hornSpacing, hornSpacingCandidate) : 0 let effectiveLegSpacing = legsEnabled ? min(legSpacing, legSpacingCandidate) : 0 let leftHornStart = left + radius + effectiveHornSpacing let rightHornStart = right - radius - effectiveHornSpacing - effectiveHornWidth let leftLegStart = left + radius + effectiveLegSpacing let rightLegStart = right - radius - effectiveLegSpacing - effectiveLegWidth path.move(to: CGPoint(x: left + radius, y: innerTop)) path.addQuadCurve(to: CGPoint(x: left, y: innerTop + radius), control: CGPoint(x: left, y: innerTop)) path.addLine(to: CGPoint(x: left, y: innerBottom - radius)) path.addQuadCurve(to: CGPoint(x: left + radius, y: innerBottom), control: CGPoint(x: left, y: innerBottom)) if legsEnabled { path.addLine(to: CGPoint(x: leftLegStart, y: innerBottom)) path.addQuadCurve( to: CGPoint(x: leftLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight), control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight) ) path.addQuadCurve( to: CGPoint(x: leftLegStart + effectiveLegWidth, y: innerBottom), control: CGPoint(x: leftLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight) ) path.addLine(to: CGPoint(x: rightLegStart, y: innerBottom)) path.addQuadCurve( to: CGPoint(x: rightLegStart + effectiveLegWidth / 2, y: innerBottom + legHeight), control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.15, y: innerBottom + legHeight) ) path.addQuadCurve( to: CGPoint(x: rightLegStart + effectiveLegWidth, y: innerBottom), control: CGPoint(x: rightLegStart + effectiveLegWidth * 0.85, y: innerBottom + legHeight) ) } path.addLine(to: CGPoint(x: right - radius, y: innerBottom)) path.addQuadCurve(to: CGPoint(x: right, y: innerBottom - radius), control: CGPoint(x: right, y: innerBottom)) path.addLine(to: CGPoint(x: right, y: innerTop + radius)) path.addQuadCurve(to: CGPoint(x: right - radius, y: innerTop), control: CGPoint(x: right, y: innerTop)) if hornsEnabled { let hornOutset = effectiveHornWidth * 0.45 let hornTipY = rect.minY - hornHeight * 0.35 // Right horn leans outward to the right path.addLine(to: CGPoint(x: rightHornStart + effectiveHornWidth, y: innerTop)) path.addQuadCurve( to: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset, y: hornTipY), control: CGPoint(x: rightHornStart + effectiveHornWidth + hornOutset * 0.65, y: innerTop - hornHeight * 0.35) ) path.addQuadCurve( to: CGPoint(x: rightHornStart, y: innerTop), control: CGPoint(x: rightHornStart + hornOutset * 0.25, y: innerTop - hornHeight) ) // Move across the top between horns and draw the left horn leaning outward path.addLine(to: CGPoint(x: leftHornStart + effectiveHornWidth, y: innerTop)) path.addQuadCurve( to: CGPoint(x: leftHornStart - hornOutset, y: hornTipY), control: CGPoint(x: leftHornStart + effectiveHornWidth - hornOutset * 0.65, y: innerTop - hornHeight * 0.35) ) path.addQuadCurve( to: CGPoint(x: leftHornStart, y: innerTop), control: CGPoint(x: leftHornStart - hornOutset * 0.25, y: innerTop - hornHeight) ) } path.addLine(to: CGPoint(x: left + radius, y: innerTop)) path.closeSubpath() return path } } #if canImport(UIKit) private struct LegacyMultilineTextView: UIViewRepresentable { @Binding var text: String var placeholder: String @Binding var isFocused: Bool var isEnabled: Bool var minHeight: CGFloat var maxLines: Int @Binding var calculatedHeight: CGFloat var onSubmit: (() -> Void)? func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator textView.backgroundColor = .clear textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.isScrollEnabled = false textView.font = UIFont.preferredFont(forTextStyle: .body) textView.text = text textView.returnKeyType = .send textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let placeholderLabel = context.coordinator.placeholderLabel placeholderLabel.text = placeholder placeholderLabel.font = textView.font placeholderLabel.textColor = UIColor.secondaryLabel placeholderLabel.numberOfLines = 1 placeholderLabel.translatesAutoresizingMaskIntoConstraints = false textView.addSubview(placeholderLabel) NSLayoutConstraint.activate([ placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor), placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor), placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor) ]) context.coordinator.updatePlaceholderVisibility(for: textView) DispatchQueue.main.async { Self.recalculateHeight( for: textView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines ) } return textView } func updateUIView(_ uiView: UITextView, context: Context) { context.coordinator.parent = self if uiView.text != text { uiView.text = text } uiView.isEditable = isEnabled uiView.isSelectable = isEnabled uiView.textColor = isEnabled ? UIColor.label : UIColor.secondaryLabel let placeholderLabel = context.coordinator.placeholderLabel if placeholderLabel.text != placeholder { placeholderLabel.text = placeholder } placeholderLabel.font = uiView.font context.coordinator.updatePlaceholderVisibility(for: uiView) if isFocused && !uiView.isFirstResponder { uiView.becomeFirstResponder() } else if !isFocused && uiView.isFirstResponder { uiView.resignFirstResponder() } Self.recalculateHeight( for: uiView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines ) } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } private var calculatedHeightBinding: Binding { Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 }) } private static func recalculateHeight(for textView: UITextView, result: Binding, minHeight: CGFloat, maxLines: Int) { let width = textView.bounds.width guard width > 0 else { DispatchQueue.main.async { recalculateHeight(for: textView, result: result, minHeight: minHeight, maxLines: maxLines) } return } let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude) let targetSize = textView.sizeThatFits(fittingSize) let lineHeight = textView.font?.lineHeight ?? UIFont.preferredFont(forTextStyle: .body).lineHeight let maxHeight = minHeight + lineHeight * CGFloat(max(maxLines - 1, 0)) let clampedHeight = min(max(targetSize.height, minHeight), maxHeight) let shouldScroll = targetSize.height > maxHeight + 0.5 if abs(result.wrappedValue - clampedHeight) > 0.5 { let newHeight = clampedHeight DispatchQueue.main.async { if abs(result.wrappedValue - newHeight) > 0.5 { result.wrappedValue = newHeight } } } textView.isScrollEnabled = shouldScroll } final class Coordinator: NSObject, UITextViewDelegate { var parent: LegacyMultilineTextView let placeholderLabel = UILabel() init(parent: LegacyMultilineTextView) { self.parent = parent } func textViewDidBeginEditing(_ textView: UITextView) { if !parent.isFocused { parent.isFocused = true } } func textViewDidEndEditing(_ textView: UITextView) { if parent.isFocused { parent.isFocused = false } } func textViewDidChange(_ textView: UITextView) { if parent.text != textView.text { parent.text = textView.text } updatePlaceholderVisibility(for: textView) LegacyMultilineTextView.recalculateHeight(for: textView, result: parent.calculatedHeightBinding, minHeight: parent.minHeight, maxLines: parent.maxLines) } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if text == "\n" { if let onSubmit = parent.onSubmit { DispatchQueue.main.async { onSubmit() } return false } } return true } func updatePlaceholderVisibility(for textView: UITextView) { placeholderLabel.isHidden = !textView.text.isEmpty } } } #endif // MARK: - Preview // Previews intentionally omitted - MessageItem has custom decoding-only initializer.