String trimming in a centralized manner (#1079)

* String trimming in a centralized manner

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