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