diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 3c213928..2b902bb9 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -929,7 +929,7 @@ enum DeliveryStatus: Codable, Equatable { } } -struct BitchatMessage: Codable, Equatable { +class BitchatMessage: Codable { let id: String let sender: String let content: String @@ -942,6 +942,23 @@ struct BitchatMessage: Codable, Equatable { let mentions: [String]? // Array of mentioned nicknames var deliveryStatus: DeliveryStatus? // Delivery tracking + // Cached formatted text (not included in Codable) + private var _cachedFormattedText: [String: AttributedString] = [:] + + func getCachedFormattedText(isDark: Bool) -> AttributedString? { + return _cachedFormattedText["\(isDark)"] + } + + func setCachedFormattedText(_ text: AttributedString, isDark: Bool) { + _cachedFormattedText["\(isDark)"] = text + } + + // Codable implementation + enum CodingKeys: String, CodingKey { + case id, sender, content, timestamp, isRelay, originalSender + case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus + } + init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) { self.id = id ?? UUID().uuidString self.sender = sender @@ -957,6 +974,23 @@ struct BitchatMessage: Codable, Equatable { } } +// Equatable conformance for BitchatMessage +extension BitchatMessage: Equatable { + static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool { + return lhs.id == rhs.id && + lhs.sender == rhs.sender && + lhs.content == rhs.content && + lhs.timestamp == rhs.timestamp && + lhs.isRelay == rhs.isRelay && + lhs.originalSender == rhs.originalSender && + lhs.isPrivate == rhs.isPrivate && + lhs.recipientNickname == rhs.recipientNickname && + lhs.senderPeerID == rhs.senderPeerID && + lhs.mentions == rhs.mentions && + lhs.deliveryStatus == rhs.deliveryStatus + } +} + protocol BitchatDelegate: AnyObject { func didReceiveMessage(_ message: BitchatMessage) func didConnectToPeer(_ peerID: String) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a8d84e67..c866240c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -938,9 +938,15 @@ class ChatViewModel: ObservableObject { } func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + // Check cache first + let isDark = colorScheme == .dark + if let cachedText = message.getCachedFormattedText(isDark: isDark) { + return cachedText + } + + // Not cached, format the message var result = AttributedString() - let isDark = colorScheme == .dark let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) let secondaryColor = primaryColor.opacity(0.7) @@ -1075,6 +1081,9 @@ class ChatViewModel: ObservableObject { result.append(content.mergingAttributes(contentStyle)) } + // Cache the formatted text + message.setCachedFormattedText(result, isDark: isDark) + return result } @@ -2223,21 +2232,17 @@ extension ChatViewModel: BitchatDelegate { if let index = messages.firstIndex(where: { $0.id == messageID }) { let currentStatus = messages[index].deliveryStatus if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - var updatedMessage = messages[index] - updatedMessage.deliveryStatus = status - messages[index] = updatedMessage + messages[index].deliveryStatus = status } } // Update in private chats var updatedPrivateChats = privateChats - for (peerID, var chatMessages) in updatedPrivateChats { + for (peerID, chatMessages) in updatedPrivateChats { if let index = chatMessages.firstIndex(where: { $0.id == messageID }) { let currentStatus = chatMessages[index].deliveryStatus if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - var updatedMessage = chatMessages[index] - updatedMessage.deliveryStatus = status - chatMessages[index] = updatedMessage + chatMessages[index].deliveryStatus = status updatedPrivateChats[peerID] = chatMessages } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ab0d72ff..1b394e18 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -163,18 +163,20 @@ struct ContentView: View { private func messagesView(privatePeer: String?) -> some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - let messages: [BitchatMessage] = { - if let privatePeer = privatePeer { - let msgs = viewModel.getPrivateChatMessages(for: privatePeer) - return msgs - } else { - return viewModel.messages - } - }() - - ForEach(messages, id: \.id) { message in + List { + let messages: [BitchatMessage] = { + if let privatePeer = privatePeer { + let msgs = viewModel.getPrivateChatMessages(for: privatePeer) + return msgs + } else { + return viewModel.messages + } + }() + + // Implement windowing - show last 100 messages for performance + let windowedMessages = messages.suffix(100) + + ForEach(Array(windowedMessages), id: \.id) { message in VStack(alignment: .leading, spacing: 4) { // Check if current user is mentioned let _ = message.mentions?.contains(viewModel.nickname) ?? false @@ -208,24 +210,26 @@ struct ContentView: View { // Don't show link preview if the message is just the emoji let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines) if cleanContent.hasPrefix("👇") { - LinkPreviewView(url: markdownLink.url, title: markdownLink.title) - .padding(.top, 2) - .id("\(message.id)-\(markdownLink.url.absoluteString)") + LazyLinkPreviewView( + url: markdownLink.url, + title: markdownLink.title, + id: "\(message.id)-\(markdownLink.url.absoluteString)" + ) } } else { // Check for plain URLs let urls = message.content.extractURLs() ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in - LinkPreviewView(url: urlInfo.url, title: nil) - .padding(.top, 2) - .id("\(message.id)-\(urlInfo.url.absoluteString)") + LazyLinkPreviewView( + url: urlInfo.url, + title: nil, + id: "\(message.id)-\(urlInfo.url.absoluteString)" + ) } } } } } - .padding(.horizontal, 12) - .padding(.vertical, 2) .id(message.id) .contentShape(Rectangle()) .onTapGesture { @@ -236,10 +240,13 @@ struct ContentView: View { showMessageActions = true } } + .listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12)) + .listRowSeparator(.hidden) + .listRowBackground(backgroundColor) } - } - .padding(.vertical, 8) } + .listStyle(.plain) + .scrollContentBackground(.hidden) .background(backgroundColor) .onTapGesture(count: 3) { // Triple-tap to clear current chat @@ -248,7 +255,9 @@ struct ContentView: View { .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { withAnimation { - proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) + // Scroll to last message in windowed view + let lastId = viewModel.messages.suffix(100).last?.id + proxy.scrollTo(lastId, anchor: .bottom) } } } @@ -257,7 +266,9 @@ struct ContentView: View { let messages = viewModel.privateChats[peerID], !messages.isEmpty { withAnimation { - proxy.scrollTo(messages.last?.id, anchor: .bottom) + // Scroll to last message in windowed view + let lastId = messages.suffix(100).last?.id + proxy.scrollTo(lastId, anchor: .bottom) } } } @@ -940,6 +951,34 @@ struct MessageContentView: View { } } +// Lazy loading wrapper for LinkPreviewView +struct LazyLinkPreviewView: View { + let url: URL + let title: String? + let id: String + + @State private var shouldLoad = false + + var body: some View { + Group { + if shouldLoad { + LinkPreviewView(url: url, title: title) + .padding(.top, 2) + .id(id) + } + } + .onAppear { + // Delay loading to improve scroll performance + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + shouldLoad = true + } + } + .onDisappear { + shouldLoad = false + } + } +} + // Delivery status indicator view struct DeliveryStatusView: View { let status: DeliveryStatus