mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:05:19 +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
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user