Merge pull request #310 from permissionlesstech/fix/ui-polish

UI Polish and Performance Optimizations
This commit is contained in:
jack
2025-07-24 14:23:41 +02:00
committed by GitHub
6 changed files with 622 additions and 324 deletions
+25 -15
View File
@@ -98,14 +98,12 @@ struct BitchatApp: App {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"],
let title = urlData["title"] {
// Send just emoji with hidden markdown link
let markdownLink = "👇 [\(title)](\(url))"
self.chatViewModel.sendMessage(markdownLink)
let url = urlData["url"] {
// Send plain URL
self.chatViewModel.sendMessage(url)
} else {
// Fallback to simple URL
self.chatViewModel.sendMessage("Shared link: \(sharedContent)")
self.chatViewModel.sendMessage(sharedContent)
}
} else {
self.chatViewModel.sendMessage(sharedContent)
@@ -147,17 +145,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Extract sender from notification title
let title = response.notification.request.content.title
if let senderName = title.replacingOccurrences(of: "Private message from ", with: "").nilIfEmpty {
// Find peer ID and open chat
if let peerID = chatViewModel?.getPeerIDForNickname(senderName) {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID)
}
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID)
}
}
}
@@ -166,7 +161,22 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// Show notification even when app is in foreground (for testing)
let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
}
}
}
// Show notification in all other cases
completionHandler([.banner, .sound])
}
}
+35 -1
View File
@@ -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)
+7 -3
View File
@@ -29,7 +29,7 @@ class NotificationService {
}
}
func sendLocalNotification(title: String, body: String, identifier: String) {
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
// For now, skip app state check entirely to avoid thread issues
// The NotificationDelegate will handle foreground presentation
DispatchQueue.main.async {
@@ -37,6 +37,9 @@ class NotificationService {
content.title = title
content.body = body
content.sound = .default
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
@@ -58,12 +61,13 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String) {
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
let title = "🔒 private message from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier)
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
func sendFavoriteOnlineNotification(nickname: String) {
+319 -150
View File
@@ -17,15 +17,15 @@ import UIKit
class ChatViewModel: ObservableObject {
@Published var messages: [BitchatMessage] = []
private let maxMessages = 1337 // Maximum messages before oldest are removed
@Published var connectedPeers: [String] = []
@Published var nickname: String = "" {
didSet {
nicknameSaveTimer?.invalidate()
nicknameSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
self.saveNickname()
}
}
}
// Message batching for performance
private var pendingMessages: [BitchatMessage] = []
private var pendingPrivateMessages: [String: [BitchatMessage]] = [:] // peerID -> messages
private var messageBatchTimer: Timer?
private let messageBatchInterval: TimeInterval = 0.1 // 100ms batching window
@Published var nickname: String = ""
@Published var isConnected = false
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
@Published var selectedPrivateChatPeer: String? = nil
@@ -36,13 +36,21 @@ class ChatViewModel: ObservableObject {
@Published var autocompleteRange: NSRange? = nil
@Published var selectedAutocompleteIndex: Int = 0
// Autocomplete optimization
private let mentionRegex = try? NSRegularExpression(pattern: "@([a-zA-Z0-9_]*)$", options: [])
private var cachedNicknames: [String] = []
private var lastNicknameUpdate: Date = .distantPast
// Temporary property to fix compilation
@Published var showPasswordPrompt = false
var meshService = BluetoothMeshService()
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
private var nicknameSaveTimer: Timer?
// Caches for expensive computations
private var rssiColorCache: [String: Color] = [:] // key: "\(rssi)_\(isDark)"
private var encryptionStatusCache: [String: EncryptionStatus] = [:] // key: peerID
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints
@@ -151,6 +159,9 @@ class ChatViewModel: ObservableObject {
}
deinit {
// Clean up timer
messageBatchTimer?.invalidate()
// Force immediate save
userDefaults.synchronize()
}
@@ -172,6 +183,14 @@ class ChatViewModel: ObservableObject {
meshService.sendBroadcastAnnounce()
}
func validateAndSaveNickname() {
// Check if nickname is empty or just whitespace
if nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
nickname = "anon\(Int.random(in: 1000...9999))"
}
saveNickname()
}
private func loadFavorites() {
// Load favorites from secure storage
favoritePeers = SecureIdentityStateManager.shared.getFavorites()
@@ -317,6 +336,7 @@ class ChatViewModel: ObservableObject {
privateChats[currentPeerID] = []
}
privateChats[currentPeerID]?.append(contentsOf: oldMessages)
trimPrivateChatMessagesIfNeeded(for: currentPeerID)
privateChats.removeValue(forKey: oldPeerID)
}
@@ -372,8 +392,12 @@ class ChatViewModel: ObservableObject {
mentions: mentions.isEmpty ? nil : mentions,
)
// Add to main messages
// Add to main messages immediately for user feedback
messages.append(message)
trimMessagesIfNeeded()
// Force immediate UI update for user's own messages
objectWillChange.send()
// Send via mesh with mentions
meshService.sendMessage(content, mentions: mentions)
@@ -420,12 +444,13 @@ class ChatViewModel: ObservableObject {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: peerID)
// Track the message for delivery confirmation
let isFavorite = isFavorite(peerID: peerID)
DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite)
// Trigger UI update
// Immediate UI update for user's own messages
objectWillChange.send()
// Send via mesh with the same message ID
@@ -501,6 +526,7 @@ class ChatViewModel: ObservableObject {
// Initialize chat history with migrated messages if any
if !migratedMessages.isEmpty {
privateChats[peerID] = migratedMessages.sorted { $0.timestamp < $1.timestamp }
trimPrivateChatMessagesIfNeeded(for: peerID)
} else {
privateChats[peerID] = []
}
@@ -561,6 +587,7 @@ class ChatViewModel: ObservableObject {
privateChats[peerID] = []
}
privateChats[peerID]?.append(localNotification)
trimPrivateChatMessagesIfNeeded(for: peerID)
} else {
// In public chat - send to everyone
@@ -573,15 +600,21 @@ class ChatViewModel: ObservableObject {
timestamp: Date(),
isRelay: false
)
messages.append(localNotification)
// System messages can be batched
addMessageToBatch(localNotification)
}
}
@objc private func appWillResignActive() {
// Flush any pending messages when app goes to background
flushMessageBatchImmediately()
userDefaults.synchronize()
}
@objc func applicationWillTerminate() {
// Flush any pending messages immediately
flushMessageBatchImmediately()
// Verify identity key is still there
_ = KeychainManager.shared.verifyIdentityKeyExists()
@@ -593,6 +626,9 @@ class ChatViewModel: ObservableObject {
}
@objc private func appWillTerminate() {
// Flush any pending messages immediately
flushMessageBatchImmediately()
userDefaults.synchronize()
}
@@ -696,6 +732,9 @@ class ChatViewModel: ObservableObject {
// PANIC: Emergency data clearing for activist safety
func panicClearAllData() {
// Flush any pending messages immediately before clearing
flushMessageBatchImmediately()
// Clear all messages
messages.removeAll()
privateChats.removeAll()
@@ -743,6 +782,10 @@ class ChatViewModel: ObservableObject {
// Clear read receipt tracking
sentReadReceipts.removeAll()
// Clear all caches
invalidateEncryptionCache()
invalidateRSSIColorCache()
// Disconnect from all peers and clear persistent identity
// This will force creation of a new identity (new fingerprint) on next launch
meshService.emergencyDisconnectAll()
@@ -765,71 +808,110 @@ class ChatViewModel: ObservableObject {
func getRSSIColor(rssi: Int, colorScheme: ColorScheme) -> Color {
let isDark = colorScheme == .dark
let cacheKey = "\(rssi)_\(isDark)"
// Check cache first
if let cachedColor = rssiColorCache[cacheKey] {
return cachedColor
}
// RSSI typically ranges from -30 (excellent) to -90 (poor)
// We'll map this to colors from green (strong) to red (weak)
let color: Color
if rssi >= -50 {
// Excellent signal: bright green
return isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0)
color = isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0)
} else if rssi >= -60 {
// Good signal: green-yellow
return isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0)
color = isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0)
} else if rssi >= -70 {
// Fair signal: yellow
return isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0)
color = isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0)
} else if rssi >= -80 {
// Weak signal: orange
return isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0)
color = isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0)
} else {
// Poor signal: red
return isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0)
color = isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0)
}
// Cache the result
rssiColorCache[cacheKey] = color
return color
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
// Quick early exit for empty text
guard cursorPosition > 0 else {
if showAutocomplete {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
}
return
}
// Find @ symbol before cursor
let beforeCursor = String(text.prefix(cursorPosition))
// Look for @ pattern
let pattern = "@([a-zA-Z0-9_]*)$"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
// Use cached regex
guard let regex = mentionRegex,
let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
if showAutocomplete {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
}
return
}
// Extract the partial nickname
let partialRange = match.range(at: 1)
guard let range = Range(partialRange, in: beforeCursor) else {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
if showAutocomplete {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
}
return
}
let partial = String(beforeCursor[range]).lowercased()
// Get all available nicknames (excluding self)
let peerNicknames = meshService.getPeerNicknames()
let allNicknames = Array(peerNicknames.values)
// Update cached nicknames only if peer list changed (check every 1 second max)
let now = Date()
if now.timeIntervalSince(lastNicknameUpdate) > 1.0 || cachedNicknames.isEmpty {
let peerNicknames = meshService.getPeerNicknames()
cachedNicknames = Array(peerNicknames.values).sorted()
lastNicknameUpdate = now
}
// Filter suggestions
let suggestions = allNicknames.filter { nick in
// Filter suggestions using cached nicknames
let suggestions = cachedNicknames.filter { nick in
nick.lowercased().hasPrefix(partial)
}.sorted()
}
// Batch UI updates
if !suggestions.isEmpty {
autocompleteSuggestions = suggestions
showAutocomplete = true
autocompleteRange = match.range(at: 0) // Store full @mention range
// Only update if suggestions changed
if autocompleteSuggestions != suggestions {
autocompleteSuggestions = suggestions
}
if !showAutocomplete {
showAutocomplete = true
}
if autocompleteRange != match.range(at: 0) {
autocompleteRange = match.range(at: 0)
}
selectedAutocompleteIndex = 0
} else {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
selectedAutocompleteIndex = 0
if showAutocomplete {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
selectedAutocompleteIndex = 0
}
}
}
@@ -855,16 +937,8 @@ class ChatViewModel: ObservableObject {
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
if message.sender == nickname {
return primaryColor
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
let rssi = meshService.getPeerRSSI()[peerID] {
// Use actual RSSI value
return getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
} else {
// No RSSI data available - use a neutral color
return primaryColor.opacity(0.7)
}
// Always use the same color for all senders - no RSSI-based coloring
return primaryColor
}
@@ -938,9 +1012,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)
@@ -956,50 +1036,15 @@ class ChatViewModel: ObservableObject {
let sender = AttributedString("<@\(message.sender)> ")
var senderStyle = AttributeContainer()
// Get sender color
let senderColor: Color
if message.sender == nickname {
senderColor = primaryColor
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
let rssi = meshService.getPeerRSSI()[peerID] {
// Use actual RSSI value
senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
} else {
// No RSSI data available - use a neutral color
senderColor = primaryColor.opacity(0.7)
}
senderStyle.foregroundColor = senderColor
senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced)
// Use consistent color for all senders
senderStyle.foregroundColor = primaryColor
// Bold the user's own nickname
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
// Process content with hashtags, mentions, and markdown links
var content = message.content
// First, check if content starts with 👇 followed by markdown link
if content.hasPrefix("👇 [") {
// This is a URL share - remove everything after the emoji
if let linkStart = content.firstIndex(of: "[") {
let indexBeforeLink = content.index(before: linkStart)
content = String(content[..<indexBeforeLink])
}
} else {
// Handle normal markdown links
let markdownLinkPattern = #"\[([^\]]+)\]\(([^)]+)\)"#
if let markdownRegex = try? NSRegularExpression(pattern: markdownLinkPattern, options: []) {
let markdownMatches = markdownRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
// Process matches in reverse order to maintain string indices
for match in markdownMatches.reversed() {
if let fullRange = Range(match.range, in: content),
let titleRange = Range(match.range(at: 1), in: content) {
// Normal markdown link - replace with just the title
let linkTitle = String(content[titleRange])
content.replaceSubrange(fullRange, with: linkTitle)
}
}
}
}
// Process content with hashtags and mentions
let content = message.content
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionPattern = "@([a-zA-Z0-9_]+)"
@@ -1007,8 +1052,12 @@ class ChatViewModel: ObservableObject {
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
// Use NSDataDetector for URL detection
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
// Combine and sort matches
var allMatches: [(range: NSRange, type: String)] = []
@@ -1018,6 +1067,9 @@ class ChatViewModel: ObservableObject {
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in urlMatches {
allMatches.append((match.range, "url"))
}
allMatches.sort { $0.range.location < $1.range.location }
// Build content with styling
@@ -1048,6 +1100,9 @@ class ChatViewModel: ObservableObject {
matchStyle.underlineStyle = .single
} else if type == "mention" {
matchStyle.foregroundColor = Color.orange
} else if type == "url" {
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
}
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
@@ -1075,6 +1130,9 @@ class ChatViewModel: ObservableObject {
result.append(content.mergingAttributes(contentStyle))
}
// Cache the formatted text
message.setCachedFormattedText(result, isDark: isDark)
return result
}
@@ -1101,21 +1159,11 @@ class ChatViewModel: ObservableObject {
let sender = AttributedString("<\(message.sender)> ")
var senderStyle = AttributeContainer()
// Get RSSI-based color
let senderColor: Color
if message.sender == nickname {
senderColor = primaryColor
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
let rssi = meshService.getPeerRSSI()[peerID] {
// Use actual RSSI value
senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
} else {
// No RSSI data available - use a neutral color
senderColor = primaryColor.opacity(0.7)
}
senderStyle.foregroundColor = senderColor
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
// Use consistent color for all senders
senderStyle.foregroundColor = primaryColor
// Bold the user's own nickname
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
senderStyle.font = .system(size: 12, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
@@ -1202,6 +1250,9 @@ class ChatViewModel: ObservableObject {
peerEncryptionStatus[peerID] = Optional.none
}
// Invalidate cache when encryption status changes
invalidateEncryptionCache(for: peerID)
// Force UI update
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
@@ -1209,6 +1260,11 @@ class ChatViewModel: ObservableObject {
}
func getEncryptionStatus(for peerID: String) -> EncryptionStatus {
// Check cache first
if let cachedStatus = encryptionStatusCache[peerID] {
return cachedStatus
}
// This must be a pure function - no state mutations allowed
// to avoid SwiftUI update loops
@@ -1216,34 +1272,143 @@ class ChatViewModel: ObservableObject {
let hasSession = meshService.getNoiseService().hasSession(with: peerID)
let storedStatus = peerEncryptionStatus[peerID]
let status: EncryptionStatus
// First check if we have an established session
if hasEstablished {
// We have encryption, now check if it's verified
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
return .noiseVerified
status = .noiseVerified
} else {
return .noiseSecured
status = .noiseSecured
}
} else {
// We have a session but no fingerprint yet - still secured
status = .noiseSecured
}
// We have a session but no fingerprint yet - still secured
return .noiseSecured
} else if hasSession {
// Check if handshaking
status = .noiseHandshaking
} else {
// Fall back to stored status
status = storedStatus ?? .none
}
// Check if handshaking
if hasSession {
return .noiseHandshaking
}
// Fall back to stored status
let finalStatus = storedStatus ?? .none
// Cache the result
encryptionStatusCache[peerID] = status
// Only log occasionally to avoid spam
if Int.random(in: 0..<100) == 0 {
SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(finalStatus)", category: SecureLogger.security, level: .debug)
SecureLogger.log("getEncryptionStatus for \(peerID): hasEstablished=\(hasEstablished), hasSession=\(hasSession), stored=\(String(describing: storedStatus)), final=\(status)", category: SecureLogger.security, level: .debug)
}
return finalStatus
return status
}
// Clear caches when data changes
private func invalidateEncryptionCache(for peerID: String? = nil) {
if let peerID = peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
private func invalidateRSSIColorCache() {
rssiColorCache.removeAll()
}
// Trim messages to keep only the most recent maxMessages
private func trimMessagesIfNeeded() {
if messages.count > maxMessages {
let removeCount = messages.count - maxMessages
messages.removeFirst(removeCount)
}
}
// Trim private chat messages to keep only the most recent maxMessages
private func trimPrivateChatMessagesIfNeeded(for peerID: String) {
if let count = privateChats[peerID]?.count, count > maxMessages {
let removeCount = count - maxMessages
privateChats[peerID]?.removeFirst(removeCount)
}
}
// MARK: - Message Batching
private func addMessageToBatch(_ message: BitchatMessage) {
pendingMessages.append(message)
scheduleBatchFlush()
}
private func addPrivateMessageToBatch(_ message: BitchatMessage, for peerID: String) {
if pendingPrivateMessages[peerID] == nil {
pendingPrivateMessages[peerID] = []
}
pendingPrivateMessages[peerID]?.append(message)
scheduleBatchFlush()
}
private func scheduleBatchFlush() {
// Cancel existing timer
messageBatchTimer?.invalidate()
// Schedule new flush
messageBatchTimer = Timer.scheduledTimer(withTimeInterval: messageBatchInterval, repeats: false) { [weak self] _ in
self?.flushMessageBatch()
}
}
private func flushMessageBatch() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Process pending public messages
if !self.pendingMessages.isEmpty {
let messagesToAdd = self.pendingMessages
self.pendingMessages.removeAll()
// Add all messages at once
self.messages.append(contentsOf: messagesToAdd)
// Sort once after batch addition
self.messages.sort { $0.timestamp < $1.timestamp }
// Trim once if needed
self.trimMessagesIfNeeded()
}
// Process pending private messages
if !self.pendingPrivateMessages.isEmpty {
let privateMessageBatches = self.pendingPrivateMessages
self.pendingPrivateMessages.removeAll()
for (peerID, messagesToAdd) in privateMessageBatches {
if self.privateChats[peerID] == nil {
self.privateChats[peerID] = []
}
// Add all messages for this peer at once
self.privateChats[peerID]?.append(contentsOf: messagesToAdd)
// Sort once after batch addition
self.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// Trim once if needed
self.trimPrivateChatMessagesIfNeeded(for: peerID)
}
}
// Single UI update for all changes
self.objectWillChange.send()
}
}
// Force immediate flush for high-priority messages
private func flushMessageBatchImmediately() {
messageBatchTimer?.invalidate()
flushMessageBatch()
}
// Update encryption status in appropriate places, not during view updates
@@ -1267,6 +1432,9 @@ class ChatViewModel: ObservableObject {
peerEncryptionStatus[peerID] = Optional.none
}
// Invalidate cache when encryption status changes
invalidateEncryptionCache(for: peerID)
// Trigger UI update
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
@@ -1386,6 +1554,9 @@ class ChatViewModel: ObservableObject {
SecureLogger.log("ChatViewModel: Setting encryption status to noiseSecured for \(peerID)", category: SecureLogger.security, level: .info)
}
// Invalidate cache when encryption status changes
self.invalidateEncryptionCache(for: peerID)
// Force UI update
self.objectWillChange.send()
}
@@ -1394,7 +1565,14 @@ class ChatViewModel: ObservableObject {
// Set up handshake required callback
noiseService.onHandshakeRequired = { [weak self] peerID in
DispatchQueue.main.async {
self?.peerEncryptionStatus[peerID] = .noiseHandshaking
guard let self = self else { return }
self.peerEncryptionStatus[peerID] = .noiseHandshaking
// Invalidate cache when encryption status changes
self.invalidateEncryptionCache(for: peerID)
// Force UI update
self.objectWillChange.send()
}
}
}
@@ -1811,6 +1989,7 @@ extension ChatViewModel: BitchatDelegate {
// Initialize with migrated messages
privateChats[peerID] = migratedMessages
trimPrivateChatMessagesIfNeeded(for: peerID)
}
if privateChats[peerID] == nil {
@@ -1849,15 +2028,11 @@ extension ChatViewModel: BitchatDelegate {
)
}
privateChats[peerID]?.append(messageToStore)
// Sort messages by timestamp to ensure proper ordering
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// Use batching for private messages
addPrivateMessageToBatch(messageToStore, for: peerID)
// Debug logging
// Trigger UI update for private chats
objectWillChange.send()
// Check if we're in a private chat with this peer's fingerprint
// This handles reconnections with new peer IDs
if let chatFingerprint = selectedPrivateChatFingerprint,
@@ -1925,9 +2100,7 @@ extension ChatViewModel: BitchatDelegate {
if finalMessage.sender != nickname && finalMessage.sender != "system" {
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messages.append(finalMessage)
// Sort messages by timestamp to ensure proper ordering
messages.sort { $0.timestamp < $1.timestamp }
addMessageToBatch(finalMessage)
}
} else if finalMessage.sender != "system" {
// Our own message - check if we already have it (by ID and content)
@@ -1948,15 +2121,13 @@ extension ChatViewModel: BitchatDelegate {
// This is a message we sent from another device or it's missing locally
// Skip empty or whitespace-only messages
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messages.append(finalMessage)
messages.sort { $0.timestamp < $1.timestamp }
addMessageToBatch(finalMessage)
}
}
} else {
// System message - check for empty content before adding
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messages.append(finalMessage)
messages.sort { $0.timestamp < $1.timestamp }
addMessageToBatch(finalMessage)
}
}
}
@@ -1968,7 +2139,10 @@ extension ChatViewModel: BitchatDelegate {
if isMentioned && message.sender != nickname {
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
} else if message.isPrivate && message.sender != nickname {
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content)
// Only send notification if the private chat is not currently open
if selectedPrivateChatPeer != message.senderPeerID {
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content, peerID: message.senderPeerID ?? "")
}
}
#if os(iOS)
@@ -2079,10 +2253,8 @@ extension ChatViewModel: BitchatDelegate {
isRelay: false,
originalSender: nil
)
messages.append(systemMessage)
// Force UI update
objectWillChange.send()
// Batch system messages
addMessageToBatch(systemMessage)
}
func didDisconnectFromPeer(_ peerID: String) {
@@ -2113,10 +2285,8 @@ extension ChatViewModel: BitchatDelegate {
isRelay: false,
originalSender: nil
)
messages.append(systemMessage)
// Force UI update
objectWillChange.send()
// Batch system messages
addMessageToBatch(systemMessage)
}
func didUpdatePeerList(_ peers: [String]) {
@@ -2133,6 +2303,9 @@ extension ChatViewModel: BitchatDelegate {
// Update encryption status for all peers
self.updateEncryptionStatusForPeers()
// Invalidate RSSI cache since peer data may have changed
self.invalidateRSSIColorCache()
// Explicitly notify SwiftUI that the object has changed.
self.objectWillChange.send()
@@ -2220,21 +2393,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
}
}
+234 -123
View File
@@ -8,6 +8,42 @@
import SwiftUI
// Pre-computed peer data for performance
struct PeerDisplayData: Identifiable {
let id: String
let displayName: String
let rssi: Int?
let isFavorite: Bool
let isMe: Bool
let hasUnreadMessages: Bool
let encryptionStatus: EncryptionStatus
}
// Lazy loading wrapper for link previews
struct LazyLinkPreviewView: View {
let url: URL
let title: String?
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
if isVisible {
LinkPreviewView(url: url, title: title)
} else {
// Placeholder while not visible
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.1))
.frame(height: 80)
.onAppear {
// Only load when view appears on screen
isVisible = true
}
}
}
.frame(height: 80)
}
}
struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@State private var messageText = ""
@@ -22,6 +58,13 @@ struct ContentView: View {
@State private var commandSuggestions: [String] = []
@State private var backSwipeOffset: CGFloat = 0
@State private var showPrivateChat = false
@State private var showMessageActions = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String?
@FocusState private var isNicknameFieldFocused: Bool
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
@@ -50,7 +93,7 @@ struct ContentView: View {
insertion: .move(edge: .trailing),
removal: .move(edge: .trailing)
))
.offset(x: showPrivateChat ? 0 : geometry.size.width)
.offset(x: showPrivateChat ? -1 : geometry.size.width)
.offset(x: backSwipeOffset)
.gesture(
DragGesture()
@@ -61,19 +104,18 @@ struct ContentView: View {
}
.onEnded { value in
if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeOut(duration: 0.2)) {
showPrivateChat = false
backSwipeOffset = 0
viewModel.endPrivateChat()
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeOut(duration: 0.15)) {
backSwipeOffset = 0
}
}
}
)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPrivateChat)
}
// Sidebar overlay
@@ -82,30 +124,40 @@ struct ContentView: View {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
sidebarView
#if os(macOS)
.frame(width: min(300, geometry.size.width * 0.4))
#else
.frame(width: geometry.size.width * 0.7)
#endif
.transition(.move(edge: .trailing))
// Only render sidebar content when it's visible or animating
if showSidebar || sidebarDragOffset != 0 {
sidebarView
#if os(macOS)
.frame(width: min(300, geometry.size.width * 0.4))
#else
.frame(width: geometry.size.width * 0.7)
#endif
.transition(.move(edge: .trailing))
} else {
// Empty placeholder when hidden
Color.clear
#if os(macOS)
.frame(width: min(300, geometry.size.width * 0.4))
#else
.frame(width: geometry.size.width * 0.7)
#endif
}
}
.offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
.animation(.easeInOut(duration: 0.25), value: showSidebar)
}
}
#if os(macOS)
.frame(minWidth: 600, minHeight: 400)
#endif
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeInOut(duration: 0.2)) {
showPrivateChat = newValue != nil
}
}
@@ -120,12 +172,52 @@ struct ContentView: View {
FingerprintView(viewModel: viewModel, peerID: peerID)
}
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? "Actions",
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("private message") {
if let peerID = selectedMessageSenderID {
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}
Button("hug") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/hug @\(sender)")
}
}
Button("slap") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/slap @\(sender)")
}
}
Button("BLOCK", role: .destructive) {
if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
}
Button("cancel", role: .cancel) {}
}
.onDisappear {
// Clean up timers
scrollThrottleTimer?.invalidate()
autocompleteDebounceTimer?.invalidate()
}
}
private func messagesView(privatePeer: String?) -> some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 0) {
let messages: [BitchatMessage] = {
if let privatePeer = privatePeer {
let msgs = viewModel.getPrivateChatMessages(for: privatePeer)
@@ -135,8 +227,11 @@ struct ContentView: View {
}
}()
ForEach(messages, id: \.id) { message in
VStack(alignment: .leading, spacing: 4) {
// Implement windowing - show last 100 messages for performance
let windowedMessages = messages.suffix(100)
ForEach(windowedMessages, id: \.id) { message in
VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned
let _ = message.mentions?.contains(viewModel.nickname) ?? false
@@ -148,7 +243,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity, alignment: .leading)
} else {
// Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
@@ -164,33 +259,35 @@ struct ContentView: View {
}
}
// Check for links and show preview
if let markdownLink = message.content.extractMarkdownLink() {
// 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)")
}
} 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)
// Check for plain URLs
let urls = message.content.extractURLs()
if !urls.isEmpty {
ForEach(urls.prefix(3).indices, id: \.self) { index in
let urlInfo = urls[index]
LazyLinkPreviewView(url: urlInfo.url, title: nil)
.padding(.top, 3)
.padding(.horizontal, 1)
.id("\(message.id)-\(urlInfo.url.absoluteString)")
}
}
}
}
}
.id(message.id)
.contentShape(Rectangle())
.onTapGesture {
// Only show actions for messages from other users (not system or self)
if message.sender != "system" && message.sender != viewModel.nickname {
selectedMessageSender = message.sender
selectedMessageSenderID = message.senderPeerID
showMessageActions = true
}
}
.padding(.horizontal, 12)
.padding(.vertical, 2)
.id(message.id)
}
}
.padding(.vertical, 8)
.padding(.vertical, 4)
}
.background(backgroundColor)
.onTapGesture(count: 3) {
@@ -199,8 +296,19 @@ struct ContentView: View {
}
.onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty {
withAnimation {
proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom)
// Throttle scroll animations to prevent excessive UI updates
let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed
lastScrollTime = now
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
}
}
}
}
@@ -208,8 +316,17 @@ struct ContentView: View {
if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID],
!messages.isEmpty {
withAnimation {
proxy.scrollTo(messages.last?.id, anchor: .bottom)
// Same throttling for private chats
let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
}
}
}
}
@@ -236,7 +353,7 @@ struct ContentView: View {
// @mentions autocomplete
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
ForEach(viewModel.autocompleteSuggestions, id: \.self) { suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
@@ -329,33 +446,24 @@ struct ContentView: View {
}
HStack(alignment: .center, spacing: 4) {
if viewModel.selectedPrivateChatPeer != nil {
Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else {
Text("<@\(viewModel.nickname)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
}
TextField("", text: $messageText)
TextField("type a message...", text: $messageText)
.textFieldStyle(.plain)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.focused($isTextFieldFocused)
.padding(.leading, 12)
.onChange(of: messageText) { newValue in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate()
// Check for command autocomplete
// Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
let commandDescriptions = [
@@ -437,7 +545,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
Text("YOUR NETWORK")
Text("NETWORK")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
@@ -467,7 +575,7 @@ struct ContentView: View {
}
if viewModel.connectedPeers.isEmpty {
Text("no one connected...")
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
@@ -479,39 +587,39 @@ struct ContentView: View {
// Show all connected peers
let peersToShow: [String] = viewModel.connectedPeers
// Sort peers: favorites first, then alphabetically by nickname
let sortedPeers = peersToShow.sorted { peer1, peer2 in
let isFav1 = viewModel.isFavorite(peerID: peer1)
let isFav2 = viewModel.isFavorite(peerID: peer2)
if isFav1 != isFav2 {
return isFav1 // Favorites come first
// Pre-compute peer data outside ForEach to reduce overhead
let peerData = peersToShow.map { peerID in
PeerDisplayData(
id: peerID,
displayName: peerID == myPeerID ? viewModel.nickname : (peerNicknames[peerID] ?? "anon\(peerID.prefix(4))"),
rssi: peerRSSI[peerID]?.intValue,
isFavorite: viewModel.isFavorite(peerID: peerID),
isMe: peerID == myPeerID,
hasUnreadMessages: viewModel.unreadPrivateMessages.contains(peerID),
encryptionStatus: viewModel.getEncryptionStatus(for: peerID)
)
}.sorted { peer1, peer2 in
// Sort: favorites first, then alphabetically by nickname
if peer1.isFavorite != peer2.isFavorite {
return peer1.isFavorite
}
return peer1.displayName < peer2.displayName
}
let name1 = peerNicknames[peer1] ?? "anon\(peer1.prefix(4))"
let name2 = peerNicknames[peer2] ?? "anon\(peer2.prefix(4))"
return name1 < name2
}
ForEach(sortedPeers, id: \.self) { peerID in
let displayName = peerID == myPeerID ? viewModel.nickname : (peerNicknames[peerID] ?? "anon\(peerID.prefix(4))")
let rssi = peerRSSI[peerID]?.intValue
let isFavorite = viewModel.isFavorite(peerID: peerID)
let isMe = peerID == myPeerID
ForEach(peerData) { peer in
HStack(spacing: 8) {
// Signal strength indicator or unread message icon
if isMe {
if peer.isMe {
Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(textColor)
.accessibilityLabel("You")
} else if viewModel.unreadPrivateMessages.contains(peerID) {
} else if peer.hasUnreadMessages {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread message from \(displayName)")
} else if let rssi = rssi {
.accessibilityLabel("Unread message from \(peer.displayName)")
} else if let rssi = peer.rssi {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundColor(viewModel.getRSSIColor(rssi: rssi, colorScheme: colorScheme))
@@ -525,61 +633,60 @@ struct ContentView: View {
}
// Peer name
if isMe {
if peer.isMe {
HStack {
Text(displayName + " (you)")
Text(peer.displayName + " (you)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
Spacer()
}
} else {
Button(action: {
if peerNicknames[peerID] != nil {
viewModel.startPrivateChat(with: peerID)
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}) {
Text(displayName)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor)
}
.buttonStyle(.plain)
.disabled(peerNicknames[peerID] == nil)
.onTapGesture(count: 2) {
// Show fingerprint on double tap
viewModel.showFingerprint(for: peerID)
}
Text(peer.displayName)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
// Encryption status icon (after peer name)
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
Image(systemName: encryptionStatus.icon)
Image(systemName: peer.encryptionStatus.icon)
.font(.system(size: 10))
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green :
encryptionStatus == .noiseSecured ? textColor :
encryptionStatus == .noiseHandshaking ? Color.orange :
.foregroundColor(peer.encryptionStatus == .noiseVerified ? Color.green :
peer.encryptionStatus == .noiseSecured ? textColor :
peer.encryptionStatus == .noiseHandshaking ? Color.orange :
Color.red)
.accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
.accessibilityLabel("Encryption: \(peer.encryptionStatus == .noiseVerified ? "verified" : peer.encryptionStatus == .noiseSecured ? "secured" : peer.encryptionStatus == .noiseHandshaking ? "establishing" : "none")")
Spacer()
// Favorite star
Button(action: {
viewModel.toggleFavorite(peerID: peerID)
viewModel.toggleFavorite(peerID: peer.id)
}) {
Image(systemName: isFavorite ? "star.fill" : "star")
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.system(size: 12))
.foregroundColor(isFavorite ? Color.yellow : secondaryTextColor)
.foregroundColor(peer.isFavorite ? Color.yellow : secondaryTextColor)
}
.buttonStyle(.plain)
.accessibilityLabel(isFavorite ? "Remove \(displayName) from favorites" : "Add \(displayName) to favorites")
.accessibilityLabel(peer.isFavorite ? "Remove \(peer.displayName) from favorites" : "Add \(peer.displayName) to favorites")
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
if !peer.isMe && peerNicknames[peer.id] != nil {
viewModel.startPrivateChat(with: peer.id)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}
.onTapGesture(count: 2) {
if !peer.isMe {
// Show fingerprint on double tap
viewModel.showFingerprint(for: peer.id)
}
}
}
}
}
@@ -615,7 +722,7 @@ struct ContentView: View {
}
}
.onEnded { value in
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeOut(duration: 0.2)) {
if !showSidebar {
if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) {
showSidebar = true
@@ -680,11 +787,15 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 100)
.foregroundColor(textColor)
.onChange(of: viewModel.nickname) { _ in
viewModel.saveNickname()
.focused($isNicknameFieldFocused)
.onChange(of: isNicknameFieldFocused) { isFocused in
if !isFocused {
// Only validate when losing focus
viewModel.validateAndSaveNickname()
}
}
.onSubmit {
viewModel.saveNickname()
viewModel.validateAndSaveNickname()
}
}
@@ -713,7 +824,7 @@ struct ContentView: View {
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
}
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
@@ -730,7 +841,7 @@ struct ContentView: View {
let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] {
HStack {
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
withAnimation(.easeInOut(duration: 0.2)) {
showPrivateChat = false
viewModel.endPrivateChat()
}
+2 -32
View File
@@ -410,50 +410,20 @@ extension String {
func extractURLs() -> [(url: URL, range: Range<String.Index>)] {
var urls: [(URL, Range<String.Index>)] = []
// Check for markdown-style links [title](url)
let markdownPattern = #"\[([^\]]+)\]\(([^)]+)\)"#
if let regex = try? NSRegularExpression(pattern: markdownPattern) {
let matches = regex.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
for match in matches {
if let urlRange = Range(match.range(at: 2), in: self),
let url = URL(string: String(self[urlRange])),
let fullRange = Range(match.range, in: self) {
urls.append((url, fullRange))
}
}
}
// Also check for plain URLs
// Check for plain URLs
let types: NSTextCheckingResult.CheckingType = .link
if let detector = try? NSDataDetector(types: types.rawValue) {
let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
for match in matches {
if let range = Range(match.range, in: self),
let url = match.url {
// Don't add if this URL is already part of a markdown link
let isPartOfMarkdown = urls.contains { $0.1.overlaps(range) }
if !isPartOfMarkdown {
urls.append((url, range))
}
urls.append((url, range))
}
}
}
return urls
}
func extractMarkdownLink() -> (title: String, url: URL)? {
let pattern = #"\[([^\]]+)\]\(([^)]+)\)"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: self, range: NSRange(location: 0, length: self.utf16.count)) {
if let titleRange = Range(match.range(at: 1), in: self),
let urlRange = Range(match.range(at: 2), in: self),
let url = URL(string: String(self[urlRange])) {
return (String(self[titleRange]), url)
}
}
return nil
}
}
#Preview {