ios_app_v2/yobble/Views/Chat/PrivateChatView.swift
2025-12-11 21:15:45 +03:00

914 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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
}
.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
}
}
}
@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: 12) {
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(viewModel.messages.reversed()) { message in
messageRow(for: message)
.id(message.id)
.scaleEffect(x: 1, y: -1, anchor: .center)
.onAppear {
guard hasPositionedToBottom else { return }
viewModel.loadMoreIfNeeded(for: 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, 12)
}
.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 message: MessageItem) -> some View {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return HStack(alignment: .bottom, spacing: 12) {
if isCurrentUser { Spacer(minLength: 32) }
messageBubble(for: message, isCurrentUser: isCurrentUser)
if !isCurrentUser { Spacer(minLength: 32) }
}
.padding(.horizontal, 16)
}
private func messageBubble(for message: MessageItem, isCurrentUser: Bool) -> some View {
let timeText = timestamp(for: message)
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(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.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 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)
}
}
)
}
}
#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<CGFloat> {
Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 })
}
private static func recalculateHeight(for textView: UITextView, result: Binding<CGFloat>, 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.