diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift new file mode 100644 index 00000000..d4390973 --- /dev/null +++ b/bitchat/Views/Components/TextMessageView.swift @@ -0,0 +1,97 @@ +// +// TextMessageView.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +struct TextMessageView: View { + @Environment(\.colorScheme) private var colorScheme: ColorScheme + @EnvironmentObject private var viewModel: ChatViewModel + + let message: BitchatMessage + @Binding var expandedMessageIDs: Set + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Precompute heavy token scans once per row + let cashuLinks = message.content.extractCashuLinks() + let lightningLinks = message.content.extractLightningLinks() + HStack(alignment: .top, spacing: 0) { + let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty + let isExpanded = expandedMessageIDs.contains(message.id) + Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) + .frame(maxWidth: .infinity, alignment: .leading) + + // Delivery status indicator for private messages + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status) + .padding(.leading, 4) + } + } + + // Expand/Collapse for very long messages + if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty { + let isExpanded = expandedMessageIDs.contains(message.id) + let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") + Button(labelKey) { + if isExpanded { expandedMessageIDs.remove(message.id) } + else { expandedMessageIDs.insert(message.id) } + } + .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(Color.blue) + .padding(.top, 4) + } + + // Render payment chips (Lightning / Cashu) with rounded background + if !lightningLinks.isEmpty || !cashuLinks.isEmpty { + HStack(spacing: 8) { + ForEach(lightningLinks, id: \.self) { link in + PaymentChipView(paymentType: .lightning(link)) + } + ForEach(cashuLinks, id: \.self) { link in + PaymentChipView(paymentType: .cashu(link)) + } + } + .padding(.top, 6) + .padding(.leading, 2) + } + } + } +} + +@available(macOS 14, iOS 17, *) +#Preview { + @Previewable @State var ids: Set = [] + let keychain = PreviewKeychainManager() + + Group { + List { + TextMessageView(message: .preview, expandedMessageIDs: $ids) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .listRowBackground(EmptyView()) + } + .environment(\.colorScheme, .light) + + List { + TextMessageView(message: .preview, expandedMessageIDs: $ids) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .listRowBackground(EmptyView()) + } + .environment(\.colorScheme, .dark) + } + .environmentObject( + ChatViewModel( + keychain: keychain, + identityManager: SecureIdentityStateManager(keychain) + ) + ) +} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 06d7fdbc..f962f594 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -308,54 +308,7 @@ struct ContentView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } else { - // Regular messages with natural text wrapping - VStack(alignment: .leading, spacing: 0) { - // Precompute heavy token scans once per row - let cashuLinks = message.content.extractCashuLinks() - let lightningLinks = message.content.extractLightningLinks() - HStack(alignment: .top, spacing: 0) { - let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty - let isExpanded = expandedMessageIDs.contains(message.id) - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) - .frame(maxWidth: .infinity, alignment: .leading) - - // Delivery status indicator for private messages - if message.isPrivate && message.sender == viewModel.nickname, - let status = message.deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) - } - } - - // Expand/Collapse for very long messages - if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty { - let isExpanded = expandedMessageIDs.contains(message.id) - let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") - Button(labelKey) { - if isExpanded { expandedMessageIDs.remove(message.id) } - else { expandedMessageIDs.insert(message.id) } - } - .font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(Color.blue) - .padding(.top, 4) - } - - // Render payment chips (Lightning / Cashu) with rounded background - if !lightningLinks.isEmpty || !cashuLinks.isEmpty { - HStack(spacing: 8) { - ForEach(lightningLinks, id: \.self) { link in - PaymentChipView(paymentType: .lightning(link)) - } - ForEach(cashuLinks, id: \.self) { link in - PaymentChipView(paymentType: .cashu(link)) - } - } - .padding(.top, 6) - .padding(.leading, 2) - } - } + TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs) } } .id(item.uiID) @@ -1038,19 +991,6 @@ struct ContentView: View { .foregroundColor(textColor) } } - - // Split a name into base and a '#abcd' suffix if present - private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) { - guard name.count >= 5 else { return (name, "") } - let suffix = String(name.suffix(5)) - if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in - ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) - }) { - let base = String(name.dropLast(5)) - return (base, suffix) - } - return (name, "") - } // Compute channel-aware people count and color for toolbar (cross-platform) private func channelPeopleCountAndColor() -> (Int, Color) { diff --git a/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift new file mode 100644 index 00000000..25a530cd --- /dev/null +++ b/bitchat/_PreviewHelpers/BitchatMessage+Preview.swift @@ -0,0 +1,27 @@ +// +// BitchatMessage+Preview.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +extension BitchatMessage { + static var preview: BitchatMessage { + BitchatMessage( + id: UUID().uuidString, + sender: "John Doe", + content: "Hello", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: "Jane Doe", + senderPeerID: nil, + mentions: nil, + deliveryStatus: .sent + ) + } +} diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift new file mode 100644 index 00000000..07542c4e --- /dev/null +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -0,0 +1,41 @@ +// +// PreviewKeychainManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +final class PreviewKeychainManager: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + init() {} + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + storage[key] = keyData + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + storage[key] + } + + func deleteIdentityKey(forKey key: String) -> Bool { + storage.removeValue(forKey: key) + return true + } + + func deleteAllKeychainData() -> Bool { + storage.removeAll() + return true + } + + func secureClear(_ data: inout Data) {} + + func secureClear(_ string: inout String) {} + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } +}