mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
String trimming in a centralized manner (#1079)
* String trimming in a centralized manner * Fix empty-nickname case
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +3772,10 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Check for mentions and send notifications
|
/// Check for mentions and send notifications
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||||
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
|
safeFilename != "." && safeFilename != ".."
|
||||||
|
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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user