mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:05:19 +00:00
Optimize UI performance with message caching and List view
- Convert BitchatMessage from struct to class for efficient caching - Cache formatted AttributedStrings to avoid expensive regex on every render - Replace ScrollView+LazyVStack with native List for better cell reuse - Implement message windowing (show last 100 messages for performance) - Add LazyLinkPreviewView that defers loading for 0.5s to improve scroll performance These optimizations significantly improve scroll performance, especially with large message lists.
This commit is contained in:
@@ -929,7 +929,7 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BitchatMessage: Codable, Equatable {
|
class BitchatMessage: Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let sender: String
|
let sender: String
|
||||||
let content: String
|
let content: String
|
||||||
@@ -942,6 +942,23 @@ struct BitchatMessage: Codable, Equatable {
|
|||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
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) {
|
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.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
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 {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: String)
|
func didConnectToPeer(_ peerID: String)
|
||||||
|
|||||||
@@ -938,9 +938,15 @@ class ChatViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
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()
|
var result = AttributedString()
|
||||||
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
let secondaryColor = primaryColor.opacity(0.7)
|
let secondaryColor = primaryColor.opacity(0.7)
|
||||||
|
|
||||||
@@ -1075,6 +1081,9 @@ class ChatViewModel: ObservableObject {
|
|||||||
result.append(content.mergingAttributes(contentStyle))
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the formatted text
|
||||||
|
message.setCachedFormattedText(result, isDark: isDark)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2223,21 +2232,17 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
let currentStatus = messages[index].deliveryStatus
|
let currentStatus = messages[index].deliveryStatus
|
||||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||||
var updatedMessage = messages[index]
|
messages[index].deliveryStatus = status
|
||||||
updatedMessage.deliveryStatus = status
|
|
||||||
messages[index] = updatedMessage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in private chats
|
// Update in private chats
|
||||||
var updatedPrivateChats = privateChats
|
var updatedPrivateChats = privateChats
|
||||||
for (peerID, var chatMessages) in updatedPrivateChats {
|
for (peerID, chatMessages) in updatedPrivateChats {
|
||||||
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
|
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
|
||||||
let currentStatus = chatMessages[index].deliveryStatus
|
let currentStatus = chatMessages[index].deliveryStatus
|
||||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||||
var updatedMessage = chatMessages[index]
|
chatMessages[index].deliveryStatus = status
|
||||||
updatedMessage.deliveryStatus = status
|
|
||||||
chatMessages[index] = updatedMessage
|
|
||||||
updatedPrivateChats[peerID] = chatMessages
|
updatedPrivateChats[peerID] = chatMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,18 +163,20 @@ struct ContentView: View {
|
|||||||
|
|
||||||
private func messagesView(privatePeer: String?) -> some View {
|
private func messagesView(privatePeer: String?) -> some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
List {
|
||||||
LazyVStack(alignment: .leading, spacing: 2) {
|
let messages: [BitchatMessage] = {
|
||||||
let messages: [BitchatMessage] = {
|
if let privatePeer = privatePeer {
|
||||||
if let privatePeer = privatePeer {
|
let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
|
||||||
let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
|
return msgs
|
||||||
return msgs
|
} else {
|
||||||
} else {
|
return viewModel.messages
|
||||||
return viewModel.messages
|
}
|
||||||
}
|
}()
|
||||||
}()
|
|
||||||
|
// Implement windowing - show last 100 messages for performance
|
||||||
ForEach(messages, id: \.id) { message in
|
let windowedMessages = messages.suffix(100)
|
||||||
|
|
||||||
|
ForEach(Array(windowedMessages), id: \.id) { message in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
// Check if current user is mentioned
|
// Check if current user is mentioned
|
||||||
let _ = message.mentions?.contains(viewModel.nickname) ?? false
|
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
|
// Don't show link preview if the message is just the emoji
|
||||||
let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleanContent = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if cleanContent.hasPrefix("👇") {
|
if cleanContent.hasPrefix("👇") {
|
||||||
LinkPreviewView(url: markdownLink.url, title: markdownLink.title)
|
LazyLinkPreviewView(
|
||||||
.padding(.top, 2)
|
url: markdownLink.url,
|
||||||
.id("\(message.id)-\(markdownLink.url.absoluteString)")
|
title: markdownLink.title,
|
||||||
|
id: "\(message.id)-\(markdownLink.url.absoluteString)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for plain URLs
|
// Check for plain URLs
|
||||||
let urls = message.content.extractURLs()
|
let urls = message.content.extractURLs()
|
||||||
ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in
|
ForEach(Array(urls.prefix(3).enumerated()), id: \.offset) { index, urlInfo in
|
||||||
LinkPreviewView(url: urlInfo.url, title: nil)
|
LazyLinkPreviewView(
|
||||||
.padding(.top, 2)
|
url: urlInfo.url,
|
||||||
.id("\(message.id)-\(urlInfo.url.absoluteString)")
|
title: nil,
|
||||||
|
id: "\(message.id)-\(urlInfo.url.absoluteString)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -236,10 +240,13 @@ struct ContentView: View {
|
|||||||
showMessageActions = true
|
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)
|
.background(backgroundColor)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
// Triple-tap to clear current chat
|
// Triple-tap to clear current chat
|
||||||
@@ -248,7 +255,9 @@ struct ContentView: View {
|
|||||||
.onChange(of: viewModel.messages.count) { _ in
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
if privatePeer == nil && !viewModel.messages.isEmpty {
|
if privatePeer == nil && !viewModel.messages.isEmpty {
|
||||||
withAnimation {
|
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],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
withAnimation {
|
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
|
// Delivery status indicator view
|
||||||
struct DeliveryStatusView: View {
|
struct DeliveryStatusView: View {
|
||||||
let status: DeliveryStatus
|
let status: DeliveryStatus
|
||||||
|
|||||||
Reference in New Issue
Block a user