diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index 108bb5a6..aa2a59f0 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -42,9 +42,11 @@ extension BitchatMessage { guard let baseDirectory = Cache.shared.filesDir else { return nil } func url(for category: MimeType.Category) -> URL? { - guard content.hasPrefix(category.messagePrefix) else { return nil } - let filename = String(content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !filename.isEmpty else { return nil } + guard content.hasPrefix(category.messagePrefix), + let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty + else { + return nil + } // Check outgoing first for sent messages, incoming for received let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming" diff --git a/bitchat/Models/BitchatMessage.swift b/bitchat/Models/BitchatMessage.swift index 24b16692..11b204fc 100644 --- a/bitchat/Models/BitchatMessage.swift +++ b/bitchat/Models/BitchatMessage.swift @@ -338,7 +338,7 @@ extension BitchatMessage { extension Array where Element == BitchatMessage { /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) func cleanedAndDeduped() -> [Element] { - let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + let arr = filter { $0.content.trimmed.isEmpty == false } guard arr.count > 1 else { return arr } diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index d6ecac8d..653fc285 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -377,10 +377,9 @@ final class GeoRelayDirectory { var result: Set = [] let lines = text.split(whereSeparator: { $0.isNewline }) for (idx, raw) in lines.enumerated() { - let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if line.isEmpty { continue } + guard let line = raw.trimmedOrNilIfEmpty else { continue } if idx == 0 && line.lowercased().contains("relay url") { continue } - let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + let parts = line.split(separator: ",").map { $0.trimmed } guard parts.count >= 3 else { continue } var host = parts[0] host = host.replacingOccurrences(of: "https://", with: "") diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 3f7dc9dc..4a05eaf7 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -109,7 +109,7 @@ struct NostrProtocol { teleported: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] - if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { + if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } if teleported { @@ -152,7 +152,7 @@ struct NostrProtocol { nickname: String? = nil ) throws -> NostrEvent { var tags = [["g", geohash]] - if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { + if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } let event = NostrEvent( diff --git a/bitchat/Protocols/BinaryEncodingUtils.swift b/bitchat/Protocols/BinaryEncodingUtils.swift index 1dcfe7c1..476efcc9 100644 --- a/bitchat/Protocols/BinaryEncodingUtils.swift +++ b/bitchat/Protocols/BinaryEncodingUtils.swift @@ -28,7 +28,7 @@ extension Data { /// Whitespace is trimmed. Must have even length after prefix removal. /// - Returns: nil if the string has odd length or contains invalid hex characters. init?(hexString: String) { - var hex = hexString.trimmingCharacters(in: .whitespaces) + var hex = hexString.trimmed // Remove optional 0x prefix if hex.hasPrefix("0x") || hex.hasPrefix("0X") { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 949a9c20..34610b33 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1354,7 +1354,7 @@ final class BLEService: NSObject { let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters) candidate = candidate.components(separatedBy: invalid).joined(separator: "_") - candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + candidate = candidate.trimmed if candidate.isEmpty { candidate = defaultName } // Security: Reject dotfiles (hidden file attacks) diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 4b48f1e8..6f4f0300 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -166,7 +166,7 @@ final class CommandProcessor { } private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { - let targetName = args.trimmingCharacters(in: .whitespaces) + let targetName = args.trimmed guard !targetName.isEmpty else { return .error(message: "usage: /\(command) ") } @@ -209,7 +209,7 @@ final class CommandProcessor { } private func handleBlock(_ args: String) -> CommandResult { - let targetName = args.trimmingCharacters(in: .whitespaces) + let targetName = args.trimmed if targetName.isEmpty { // List blocked users (mesh) and geohash (Nostr) blocks @@ -284,7 +284,7 @@ final class CommandProcessor { } private func handleUnblock(_ args: String) -> CommandResult { - let targetName = args.trimmingCharacters(in: .whitespaces) + let targetName = args.trimmed guard !targetName.isEmpty else { return .error(message: "usage: /unblock ") } @@ -311,7 +311,7 @@ final class CommandProcessor { } private func handleFavorite(_ args: String, add: Bool) -> CommandResult { - let targetName = args.trimmingCharacters(in: .whitespaces) + let targetName = args.trimmed guard !targetName.isEmpty else { return .error(message: "usage: /\(add ? "fav" : "unfav") ") } diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index e9153163..22c5d0e2 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject { var displayName: String { let suffix = String(pubkey.suffix(4)) - if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let nick = nickname?.trimmedOrNilIfEmpty { return "\(nick)#\(suffix)" } return "anon#\(suffix)" @@ -199,8 +199,7 @@ final class LocationNotesManager: ObservableObject { /// Send a location note for the current geohash using the per-geohash identity. func send(content: String, nickname: String) { - let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard let trimmed = content.trimmedOrNilIfEmpty else { return } let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { state = .noRelays diff --git a/bitchat/Services/LocationStateManager.swift b/bitchat/Services/LocationStateManager.swift index 378244d5..8ac62df4 100644 --- a/bitchat/Services/LocationStateManager.swift +++ b/bitchat/Services/LocationStateManager.swift @@ -597,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl private static func normalizeGeohash(_ s: String) -> String { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") return s - .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmed .lowercased() .replacingOccurrences(of: "#", with: "") .filter { allowed.contains($0) } diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index b513bb87..f84c1f47 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -145,7 +145,7 @@ enum ContentNormalizer { } // Trim and collapse whitespace - let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = simplified.trimmed let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) // Take prefix and hash diff --git a/bitchat/Utils/InputValidator.swift b/bitchat/Utils/InputValidator.swift index c2b2fb53..e9c86868 100644 --- a/bitchat/Utils/InputValidator.swift +++ b/bitchat/Utils/InputValidator.swift @@ -21,9 +21,7 @@ struct InputValidator { /// Rejects strings containing control characters to prevent potential security issues /// and UI rendering problems. This strict approach ensures data integrity at input time. static func validateUserString(_ string: String, maxLength: Int) -> String? { - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard trimmed.count <= maxLength else { return nil } + guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil } // Reject control characters outright instead of rewriting the string. // This prevents injection attacks and ensures consistent UI rendering. diff --git a/bitchat/Utils/String+Ext.swift b/bitchat/Utils/String+Ext.swift new file mode 100644 index 00000000..49e12077 --- /dev/null +++ b/bitchat/Utils/String+Ext.swift @@ -0,0 +1,22 @@ +// +// String+Ext.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +extension StringProtocol { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + var trimmedOrNilIfEmpty: String? { + let trimmed = self.trimmed + return trimmed.isEmpty ? nil : trimmed + } + + var nilIfEmpty: Self? { + isEmpty ? nil : self + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index f54bc647..c31bae4c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -144,10 +144,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { - // Trim whitespace whenever nickname is set - let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + // Trim whitespace whenever nickname is set; whitespace-only becomes "" + let trimmed = nickname.trimmedOrNilIfEmpty ?? "" if trimmed != nickname { nickname = trimmed + return } // Update mesh service nickname if it's initialized if !meshService.myPeerID.isEmpty { @@ -747,8 +748,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { - // Trim whitespace when loading - nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines) + nickname = savedNickname.trimmed } else { nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() @@ -764,20 +764,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } func validateAndSaveNickname() { - // Trim whitespace from nickname - let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) - - // Check if nickname is empty after trimming - if trimmed.isEmpty { - nickname = "anon\(Int.random(in: 1000...9999))" - } else { - nickname = trimmed - } + nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))" saveNickname() } - - // MARK: - Favorites Management - + // MARK: - Blocked Users Management (Delegated to PeerStateManager) @@ -998,8 +988,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @MainActor func sendMessage(_ content: String) { // Ignore messages that are empty or whitespace-only to prevent blank lines - let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard let trimmed = content.trimmedOrNilIfEmpty else { return } // Check for commands if content.hasPrefix("/") { @@ -3002,7 +2991,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv Task { @MainActor in // Early validation guard !isMessageBlocked(message) else { return } - guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return } + guard !message.content.trimmed.isEmpty || message.isPrivate else { return } // Route to appropriate handler if message.isPrivate { @@ -3175,7 +3164,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { Task { @MainActor in - let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = content.trimmed let publicMentions = parseMentions(from: normalized) let msg = BitchatMessage( id: messageID, @@ -3783,10 +3772,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Removed background nudge notification for generic "new chats!" // Append via batching buffer (skip empty content) with simple dedup by ID - if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if !messages.contains(where: { $0.id == finalMessage.id }) { - publicMessagePipeline.enqueue(finalMessage) - } + if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) { + publicMessagePipeline.enqueue(finalMessage) } } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index fcd03438..c9c50206 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -77,7 +77,7 @@ extension ChatViewModel { } if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) + let nick = nickTag[1].trimmed geoNicknames[event.pubkey.lowercased()] = nick } @@ -115,8 +115,8 @@ extension ChatViewModel { } let senderName = displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) - + let content = event.content.trimmed + // Clamp future timestamps to now to avoid future-dated messages skewing order let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) @@ -192,7 +192,7 @@ extension ChatViewModel { case .mesh: refreshVisibleMessages(from: .mesh) // Debug: log if any empty messages are present - let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count + let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count if emptyMesh > 0 { SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) } @@ -308,8 +308,7 @@ extension ChatViewModel { // Cache nickname from tag if present if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) - geoNicknames[event.pubkey.lowercased()] = nick + geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed } // Store mapping for geohash DM initiation @@ -325,8 +324,10 @@ extension ChatViewModel { let content = event.content // If this is a teleport presence event (no content), don't add to timeline - if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"), - content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let teleTag = event.tags.first(where: { $0.first == "t" }), + teleTag.count >= 2, + teleTag[1] == "teleport", + content.trimmed.isEmpty { return } @@ -496,9 +497,8 @@ extension ChatViewModel { participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) // Notify only on rising-edge: previously zero people, now someone sends a chat - let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !content.isEmpty else { return } - + guard let content = event.content.trimmedOrNilIfEmpty else { return } + // Respect geohash blocks if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 31d332fb..f7f69cd7 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -548,13 +548,15 @@ extension ChatViewModel { func cleanupLocalFile(forMessage message: BitchatMessage) { // Check both outgoing and incoming directories for thorough cleanup let categories: [MimeType.Category] = [.audio, .image, .file] - guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }) else { return } - let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return } - - // Security: Extract only the last path component to prevent directory traversal - let safeFilename = (rawFilename as NSString).lastPathComponent - guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return } + guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), + let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, + let base = try? applicationFilesDirectory(), + // Security: Extract only the last path component to prevent directory traversal + let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, + safeFilename != "." && safeFilename != ".." + else { + return + } // Try all possible locations (outgoing and incoming) let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index a3e3bcaa..76e30039 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -361,8 +361,7 @@ struct ContentView: View { }() let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in - let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } + guard let trimmed = message.content.trimmedOrNilIfEmpty else { return nil } return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } @@ -766,8 +765,7 @@ struct ContentView: View { // MARK: - Actions private func sendMessage() { - let trimmed = trimmedMessageText - guard !trimmed.isEmpty else { return } + guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } // Clear input immediately for instant feedback messageText = "" @@ -1537,10 +1535,6 @@ private extension ContentView { ) } - private var trimmedMessageText: String { - messageText.trimmingCharacters(in: .whitespacesAndNewlines) - } - private var shouldShowMediaControls: Bool { if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { return true @@ -1601,7 +1595,7 @@ private extension ContentView { @ViewBuilder var sendOrMicButton: some View { - let hasText = !trimmedMessageText.isEmpty + let hasText = !messageText.trimmed.isEmpty if shouldShowVoiceControl { ZStack { micButtonView diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 774dcd5c..a54fb32e 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -302,7 +302,7 @@ struct LocationChannelsSheet: View { } } let normalized = customGeohash - .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmed .lowercased() .replacingOccurrences(of: "#", with: "") let isValid = validateGeohash(normalized) @@ -449,7 +449,7 @@ struct LocationChannelsSheet: View { // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) { guard let idx = s.lastIndex(of: "[") else { return (s, nil) } - let prefix = String(s[..