diff --git a/Sources/ExyteChat/Views/ChatView.swift b/Sources/ExyteChat/Views/ChatView.swift index 8bed8377..958b4006 100644 --- a/Sources/ExyteChat/Views/ChatView.swift +++ b/Sources/ExyteChat/Views/ChatView.swift @@ -248,7 +248,7 @@ public struct ChatView some View { diff --git a/Sources/ExyteChat/Views/MessageView/MessageMenu/MessageMenu.swift b/Sources/ExyteChat/Views/MessageView/MessageMenu/MessageMenu.swift index 2cd2c11a..593c817e 100644 --- a/Sources/ExyteChat/Views/MessageView/MessageMenu/MessageMenu.swift +++ b/Sources/ExyteChat/Views/MessageView/MessageMenu/MessageMenu.swift @@ -34,7 +34,7 @@ struct MessageMenu: View { /// The max height for the menu /// - Note: menus that exceed this value will be placed in a ScrollView - let maxMenuHeight: CGFloat = 200 + let maxMenuHeight: CGFloat = 350 /// The vertical spacing between the main three components in out VStack (ReactionSelection, Message and Menu) let verticalSpacing:CGFloat = 0 @@ -530,17 +530,18 @@ struct MessageMenu: View { HStack(spacing: 0) { ZStack { theme.colors.messageFriendBG - .cornerRadius(12) + .cornerRadius(16) // Increased from 12 to 16 HStack { Text(title) + .font(.custom("SFProRounded-Semibold", size: 15)) + .kerning(0.375) // 2.5% of 15 .foregroundColor(theme.colors.menuText) Spacer() icon .renderingMode(.template) .foregroundStyle(theme.colors.menuText) } - .font(getFont) - .padding(.vertical, 11) + .padding(.vertical, 13) // Increased from 11 to 13 (20% taller) .padding(.horizontal, 12) } .frame(width: 208) diff --git a/Sources/ExyteChat/Views/MessageView/MessageView.swift b/Sources/ExyteChat/Views/MessageView/MessageView.swift index 527e2c82..885ff367 100644 --- a/Sources/ExyteChat/Views/MessageView/MessageView.swift +++ b/Sources/ExyteChat/Views/MessageView/MessageView.swift @@ -297,19 +297,19 @@ struct MessageView: View { } timeView } - .padding(.vertical, 8) + .padding(.vertical, 6) case .vstack: VStack(alignment: .trailing, spacing: 4) { messageView timeView } - .padding(.vertical, 8) + .padding(.vertical, 6) case .overlay: messageView - .padding(.vertical, 8) + .padding(.vertical, 6) .overlay(alignment: .bottomTrailing) { timeView - .padding(.vertical, 8) + .padding(.vertical, 6) } } } diff --git a/Sources/ExyteChat/Views/UIList.swift b/Sources/ExyteChat/Views/UIList.swift index b8aaa33e..5a06dbbd 100644 --- a/Sources/ExyteChat/Views/UIList.swift +++ b/Sources/ExyteChat/Views/UIList.swift @@ -65,6 +65,8 @@ struct UIList: UIViewRepresentable { tableView.scrollsToTop = false tableView.isScrollEnabled = isScrollEnabled tableView.keyboardDismissMode = keyboardDismissMode + // Reduced content inset for tighter spacing (becomes bottom due to 180° rotation) + tableView.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0) NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in DispatchQueue.main.async { @@ -117,14 +119,68 @@ struct UIList: UIViewRepresentable { } return } + + // PERFORMANCE FIX: Fast path for single message addition (most common case) + let oldTotalRows = coordinator.sections.reduce(0) { $0 + $1.rows.count } + let newTotalRows = sections.reduce(0) { $0 + $1.rows.count } + + if newTotalRows == oldTotalRows + 1 && sections.count >= coordinator.sections.count { + if sections.count == coordinator.sections.count { + // New message in existing section + if sections[0].rows.count == coordinator.sections[0].rows.count + 1 { + coordinator.sections = sections + if let lastSection = sections.last { + coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1) + } + + tableView.beginUpdates() + tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .top) + tableView.endUpdates() + + if isScrolledToBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if !coordinator.sections.isEmpty { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + } + } + } + + if !isScrollEnabled { + tableContentHeight = tableView.contentSize.height + } + return + } + } else if sections.count == coordinator.sections.count + 1 { + // New date section created + coordinator.sections = sections + if let lastSection = sections.last { + coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1) + } + + tableView.beginUpdates() + tableView.insertSections([0], with: .top) + tableView.endUpdates() + + if isScrolledToBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if !coordinator.sections.isEmpty { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + } + } + } + + if !isScrollEnabled { + tableContentHeight = tableView.contentSize.height + } + return + } + } if let lastSection = sections.last { coordinator.paginationTargetIndexPath = IndexPath(row: lastSection.rows.count - 1, section: sections.count - 1) } let prevSections = coordinator.sections - //print("0 whole sections:", runID, "\n") - //print("whole previous:\n", formatSections(prevSections), "\n") let splitInfo = await performSplitInBackground(prevSections, sections) await applyUpdatesToTable(tableView, splitInfo: splitInfo) { coordinator.sections = $0 @@ -571,34 +627,178 @@ struct UIList: UIViewRepresentable { tableViewCell.backgroundColor = UIColor(mainBackgroundColor) let row = sections[indexPath.section].rows[indexPath.row] - tableViewCell.contentConfiguration = UIHostingConfiguration { - ChatMessageView( - viewModel: viewModel, messageBuilder: messageBuilder, row: row, chatType: type, - avatarSize: avatarSize, tapAvatarClosure: tapAvatarClosure, - messageStyler: messageStyler, shouldShowLinkPreview: shouldShowLinkPreview, - isDisplayingMessageMenu: false, showMessageTimeView: showMessageTimeView, - messageLinkPreviewLimit: messageLinkPreviewLimit, messageFont: messageFont - ) - .transition(.scale) - .background(MessageMenuPreferenceViewSetter(id: row.id)) - .rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0))) - .applyIf(showMessageMenuOnLongPress) { - $0.simultaneousGesture( - TapGesture().onEnded { } // add empty tap to prevent iOS17 scroll breaking bug (drag on cells stops working) + + // Check if this is a system message (e.g., "User joined the clan") + if row.message.user.type == .system { + tableViewCell.contentConfiguration = UIHostingConfiguration { + systemMessageView(for: row.message) + .rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0))) + } + .minSize(width: 0, height: 0) + .margins(.all, 0) + } else { + tableViewCell.contentConfiguration = UIHostingConfiguration { + ChatMessageView( + viewModel: viewModel, messageBuilder: messageBuilder, row: row, chatType: type, + avatarSize: avatarSize, tapAvatarClosure: tapAvatarClosure, + messageStyler: messageStyler, shouldShowLinkPreview: shouldShowLinkPreview, + isDisplayingMessageMenu: false, showMessageTimeView: showMessageTimeView, + messageLinkPreviewLimit: messageLinkPreviewLimit, messageFont: messageFont ) - .onLongPressGesture { - // Trigger haptic feedback - self.impactGenerator.impactOccurred() - // Launch the message menu - self.viewModel.messageMenuRow = row + .transition(.scale) + .background(MessageMenuPreferenceViewSetter(id: row.id)) + .rotationEffect(Angle(degrees: (type == .conversation ? 180 : 0))) + .applyIf(showMessageMenuOnLongPress) { + $0 .simultaneousGesture( + TapGesture().onEnded { } // add empty tap to prevent iOS17 scroll breaking bug (drag on cells stops working) + ) + .onLongPressGesture(minimumDuration: 0.05) { + // Trigger haptic feedback + self.impactGenerator.impactOccurred() + // Launch the message menu + self.viewModel.messageMenuRow = row + } } } + .minSize(width: 0, height: 0) + .margins(.all, 0) } - .minSize(width: 0, height: 0) - .margins(.all, 0) return tableViewCell } + + // System message view builder (centered, no bubble, like date headers) + @ViewBuilder + func systemMessageView(for message: Message) -> some View { + let parsedMessage = parseSystemMessage(message.text) + + HStack(spacing: 0) { + Spacer(minLength: 0) + + if parsedMessage.hasUsername { + // System message with username highlighting - allow wrapping but keep words together + (Text(parsedMessage.username) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [ + Color(red: 0, green: 0x78/255.0, blue: 1.0), // #0078FF (lighter) + Color(red: 0, green: 0, blue: 1.0) // #0000FF (darker) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + + Text(parsedMessage.action) + .font(.system(size: 14)) + .foregroundColor(.gray) + + + (parsedMessage.hasSecondUsername && parsedMessage.secondUsername != nil ? + Text(parsedMessage.secondUsername!) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [ + Color(red: 0, green: 0x78/255.0, blue: 1.0), // #0078FF (lighter) + Color(red: 0, green: 0, blue: 1.0) // #0000FF (darker) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + : Text("") + ) + + + (parsedMessage.remainingText != nil ? + Text(parsedMessage.remainingText!) + .font(.system(size: 14)) + .foregroundColor(.gray) + : Text("") + )) + .multilineTextAlignment(.center) + } else { + // Regular system message without username + Text(message.text) + .font(.system(size: 14)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + } + + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + + // Parse system messages to extract username and action + private func parseSystemMessage(_ text: String) -> (username: String, action: String, hasUsername: Bool, secondUsername: String?, hasSecondUsername: Bool, remainingText: String?) { + print("🔍 EXYTE PARSING: '\(text)'") + + // Special handling for promotion/demotion messages: "Actor promoted/demoted Target to Role" + if let promotedRange = text.range(of: " promoted ") { + let actor = String(text[.. 0 + + return (username, action, hasUsername, nil, false, nil) + } + } + + return ("", text, false, nil, false, nil) + } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let paginationHandler = self.paginationHandler, let paginationTargetIndexPath, indexPath == paginationTargetIndexPath else { diff --git a/Sources/ExyteChat/Views/WrappingMessages.swift b/Sources/ExyteChat/Views/WrappingMessages.swift index ec1896c9..7416edd3 100644 --- a/Sources/ExyteChat/Views/WrappingMessages.swift +++ b/Sources/ExyteChat/Views/WrappingMessages.swift @@ -7,17 +7,184 @@ import SwiftUI +// PERFORMANCE FIX: Cache sections AND rows for instant message updates +private class SectionCache { + private var cachedSections: [Date: MessagesSection] = [:] + private var lastMessageIDs: [String] = [] + + func clearCache() { + cachedSections.removeAll() + lastMessageIDs.removeAll() + } + + func mapMessagesWithCache(_ messages: [Message], chatType: ChatType, replyMode: ReplyMode) -> [MessagesSection] { + let messageIDs = messages.map { $0.id } + + // Group messages by date in ONE pass (fast dictionary grouping) + var messagesByDate: [Date: [Message]] = [:] + for message in messages { + let startOfDay = message.createdAt.startOfDay() + messagesByDate[startOfDay, default: []].append(message) + } + + let sortedDates = messagesByDate.keys.sorted(by: >) + var result: [MessagesSection] = [] + + // Check if only new message was added (most common case for instant updates) + let isIncrementalUpdate = lastMessageIDs.count + 1 == messageIDs.count && + lastMessageIDs.allSatisfy { messageIDs.contains($0) } + + for date in sortedDates { + guard let messagesForDate = messagesByDate[date] else { continue } + + // Check if this date section is cached and unchanged + if let cachedSection = cachedSections[date], + cachedSection.rows.count == messagesForDate.count, + !isIncrementalUpdate { + // Reuse cached section - INSTANT! + result.append(cachedSection) + continue + } + + // Build section for this date + let isFirstSection = date == sortedDates.first + let isLastSection = date == sortedDates.last + let rows = wrapMessagesForDate(messagesForDate, chatType: chatType, replyMode: replyMode, + isFirstSection: isFirstSection, isLastSection: isLastSection) + + let section = MessagesSection(date: date, rows: rows) + cachedSections[date] = section + result.append(section) + } + + lastMessageIDs = messageIDs + return result + } + + private func wrapMessagesForDate(_ messages: [Message], chatType: ChatType, replyMode: ReplyMode, isFirstSection: Bool, isLastSection: Bool) -> [MessageRow] { + messages + .enumerated() + .map { index, message in + let nextMessage = chatType == .conversation ? messages[safe: index + 1] : messages[safe: index - 1] + let prevMessage = chatType == .conversation ? messages[safe: index - 1] : messages[safe: index + 1] + + let nextMessageExists = nextMessage != nil + let prevMessageExists = prevMessage != nil + let nextMessageIsSameUser = nextMessage?.user.id == message.user.id + let prevMessageIsSameUser = prevMessage?.user.id == message.user.id + + let positionInUserGroup: PositionInUserGroup + if nextMessageExists, nextMessageIsSameUser, prevMessageIsSameUser { + positionInUserGroup = .middle + } else if !nextMessageExists || !nextMessageIsSameUser, !prevMessageIsSameUser { + positionInUserGroup = .single + } else if nextMessageExists, nextMessageIsSameUser { + positionInUserGroup = .first + } else { + positionInUserGroup = .last + } + + let positionInMessagesSection: PositionInMessagesSection + if messages.count == 1 { + positionInMessagesSection = .single + } else if !prevMessageExists { + positionInMessagesSection = .first + } else if !nextMessageExists { + positionInMessagesSection = .last + } else { + positionInMessagesSection = .middle + } + + if replyMode == .quote { + return MessageRow( + message: message, positionInUserGroup: positionInUserGroup, + positionInMessagesSection: positionInMessagesSection, commentsPosition: nil) + } + + let nextMessageIsAReply = nextMessage?.replyMessage != nil + let nextMessageIsFirstLevel = nextMessage?.replyMessage == nil + let prevMessageIsFirstLevel = prevMessage?.replyMessage == nil + + let positionInComments: PositionInCommentsGroup + if message.replyMessage == nil && !nextMessageIsAReply { + positionInComments = .singleFirstLevelPost + } else if message.replyMessage == nil && nextMessageIsAReply { + positionInComments = .firstLevelPostWithComments + } else if nextMessageIsFirstLevel { + positionInComments = .lastComment + } else if prevMessageIsFirstLevel { + positionInComments = .firstComment + } else { + positionInComments = .middleComment + } + + let positionInSection: PositionInSection + if !prevMessageExists, !nextMessageExists { + positionInSection = .single + } else if !prevMessageExists { + positionInSection = .first + } else if !nextMessageExists { + positionInSection = .last + } else { + positionInSection = .middle + } + + let positionInChat: PositionInChat + if !isFirstSection, !isLastSection { + positionInChat = .middle + } else if !prevMessageExists, !nextMessageExists, isFirstSection, isLastSection { + positionInChat = .single + } else if !prevMessageExists, isFirstSection { + positionInChat = .first + } else if !nextMessageExists, isLastSection { + positionInChat = .last + } else { + positionInChat = .middle + } + + let commentsPosition = CommentsPosition( + inCommentsGroup: positionInComments, inSection: positionInSection, + inChat: positionInChat) + + return MessageRow( + message: message, positionInUserGroup: positionInUserGroup, + positionInMessagesSection: positionInMessagesSection, + commentsPosition: commentsPosition) + } + .reversed() + } +} + +private let sectionCache = SectionCache() + +// MARK: - Public Cache Management +/// Non-generic helper to clear the global section cache +public enum ChatViewCache { + /// Clear the global section cache to prevent data bleeding between different chats + public nonisolated static func clearSectionCache() { + sectionCache.clearCache() + } +} + extension ChatView { + /// Clear the global section cache to prevent data bleeding between different chats + public nonisolated static func clearSectionCache() { + ChatViewCache.clearSectionCache() + } + nonisolated static func mapMessages(_ messages: [Message], chatType: ChatType, replyMode: ReplyMode) -> [MessagesSection] { guard messages.hasUniqueIDs() else { fatalError("Messages can not have duplicate ids, please make sure every message gets a unique id") } + guard !messages.isEmpty else { return [] } + let result: [MessagesSection] switch replyMode { case .quote: - result = mapMessagesQuoteModeReplies(messages, chatType: chatType, replyMode: replyMode) + // Use cached mapping for instant performance + result = sectionCache.mapMessagesWithCache(messages, chatType: chatType, replyMode: replyMode) case .answer: result = mapMessagesCommentModeReplies(messages, chatType: chatType, replyMode: replyMode) } @@ -180,5 +347,4 @@ extension ChatView { } .reversed() } -} - +} \ No newline at end of file