ios_app_v2/yobble/Views/Chat/PrivateChatView.swift
2025-12-12 03:10:03 +03:00

1364 lines
50 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
@AppStorage("chatBubbleDecorationsEnabled") private var areBubbleDecorationsEnabled: Bool = true
@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 themeManager: ThemeManager
@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()
}
.alert("Ошибка отправки", isPresented: Binding(
get: { viewModel.sendingErrorMessage != nil },
set: { if !$0 { viewModel.sendingErrorMessage = nil } }
), actions: {
Button("OK") { }
}, message: {
Text(viewModel.sendingErrorMessage ?? "Не удалось отправить сообщение.")
})
.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
let hasDecorations = decoratedMessage.showHorns || decoratedMessage.showLegs
let topPadding: CGFloat = decoratedMessage.showHorns ? 5 : -12
let verticalPadding: CGFloat = hasDecorations ? 0 : 0
if #available(iOS 16.0, *) {
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)
.padding(.top, topPadding)
.padding(.vertical, verticalPadding)
.contextMenu(menuItems: {
if isCurrentUser {
if message.isViewed == true {
Text("Прочитано") // Placeholder for read status
}
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Edit action
}) {
Text("Изменить")
Image(systemName: "pencil")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
} else {
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
}
}, preview: {
messageBubble(
for: message,
decorations: decoratedMessage,
isCurrentUser: isCurrentUser
)
})
} else {
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)
.padding(.top, topPadding)
.padding(.vertical, verticalPadding)
.contextMenu {
if isCurrentUser {
if message.isViewed == true {
Text("Прочитано") // Placeholder for read status
}
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Edit action
}) {
Text("Изменить")
Image(systemName: "pencil")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
} else {
Button(action: {
// Reply action
}) {
Text("Ответить")
Image(systemName: "arrowshape.turn.up.left")
}
Button(action: {
// Copy action
}) {
Text("Копировать")
Image(systemName: "doc.on.doc")
}
Button(action: {
// Pin action
}) {
Text("Закрепить")
Image(systemName: "pin")
}
Button(action: {
// Forward action
}) {
Text("Переслать")
Image(systemName: "arrowshape.turn.up.right")
}
Button(role: .destructive, action: {
// Delete action
}) {
Text("Удалить")
Image(systemName: "trash")
}
Button(action: {
// Select action
}) {
Text("Выбрать")
Image(systemName: "checkmark.circle")
}
}
}
}
}
private func messageBubble(
for message: MessageItem,
decorations: DecoratedMessage,
isCurrentUser: Bool
) -> some View {
let timeText = timestamp(for: message)
let bubbleColor = isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)
let foregroundColor = isCurrentUser ? Color.white : Color.primary
// Use a single HStack with bottom alignment to keep text and time on the same line,
// with the time aligning to the bottom.
return HStack(alignment: .bottom, spacing: 8) {
Text(contentText(for: message))
.font(.body)
.foregroundColor(foregroundColor)
.multilineTextAlignment(.leading)
// This view contains the time and the optional checkmark.
// It's pushed to the right by the expanding Text view.
HStack(spacing: 4) {
Text(timeText)
.font(.caption2)
.foregroundColor(isCurrentUser ? foregroundColor.opacity(0.85) : .secondary)
if isCurrentUser {
Image(systemName: (message.isViewed ?? false) ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(isCurrentUser ? foregroundColor.opacity(0.85) : .secondary)
}
}
.offset(y: 3) // Move the timestamp view down
}
.padding(.vertical, 15)
.padding(.horizontal, 15)
.background(
MessageBubbleShape(
decorationsEnabled: areBubbleDecorationsEnabled,
showHornsRaw: decorations.showHorns,
showLegsRaw: 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)
HStack{
if draftText.count > 2047 {
Text("\(draftText.count) / \(viewModel.maxMessageLength)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(draftText.count > viewModel.maxMessageLength ? .red : .secondary)
}
Button(action: { }) { // переключатель на стикеры
Image(systemName: "face.smiling")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
}
.padding(.trailing, 12)
.padding(.bottom, 10)
}
// if draftText.count > 300 {
// Text("\(draftText.count) / \(viewModel.maxMessageLength)")
// .font(.caption2)
// .fontWeight(.medium)
// .foregroundColor(draftText.count > viewModel.maxMessageLength ? .red : .secondary)
// .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
// .padding(.trailing, 12)
// .padding(.top, 4)
// }
}
.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)
.modifier(ComposerBackgroundModifier(theme: themeManager.theme))
}
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 }
scrollToBottomTrigger = .init()
viewModel.sendMessage(text: text) { success in
if success {
draftText = ""
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)
}
}
)
}
}
private struct ComposerBackgroundModifier: ViewModifier {
let theme: Theme
@ViewBuilder
func body(content: Content) -> some View {
if theme == .oledDark {
content
.background(Color.black.opacity(0.85)) // глубокий чёрный
// .overlay(
// Color.black.opacity(0.25) // лёгкое затемнение сверху
// .allowsHitTesting(false)
// )
// content.background(.ultraThinMaterial)
// content.background(Color.black)
// content.background(Color(white: 0.15))
} else {
content.background(.ultraThinMaterial)
}
}
}
/// 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 decorationsEnabled: Bool = true
var showHornsRaw: Bool = true
var showLegsRaw: Bool = true
var showHorns: Bool { decorationsEnabled && showHornsRaw }
var showLegs: Bool { decorationsEnabled && showLegsRaw }
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<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.