Merge branch 'main' into fix/thread-sleep-bleservice

This commit is contained in:
jack
2025-11-24 11:48:58 -10:00
committed by GitHub
5 changed files with 1232 additions and 95 deletions
@@ -0,0 +1,159 @@
//
// GeohashParticipantTracker.swift
// bitchat
//
// Tracks participants in geohash-based location channels.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a participant in a geohash channel
public struct GeoPerson: Identifiable, Equatable, Sendable {
public let id: String // pubkey hex (lowercased)
public let displayName: String
public let lastSeen: Date
public init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
}
}
/// Protocol for resolving display names and checking block status
@MainActor
public protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
}
/// Tracks participants across multiple geohash channels
@MainActor
public final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
public let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published public private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
/// Context for display name resolution and block checking
private weak var context: GeohashParticipantContext?
/// Timer for periodic refresh
private var refreshTimer: Timer?
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
public func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
public func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
} else {
refresh()
}
}
/// Record activity from a participant in the current active geohash
public func recordParticipant(pubkeyHex: String) {
guard let gh = activeGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record activity from a participant in a specific geohash
public func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = participants[geohash] ?? [:]
map[key] = Date()
participants[geohash] = map
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
refresh()
}
}
/// Remove a participant from all geohashes (used when blocking)
public func removeParticipant(pubkeyHex: String) {
let key = pubkeyHex.lowercased()
for (gh, var map) in participants {
map.removeValue(forKey: key)
participants[gh] = map
}
refresh()
}
/// Get participant count for a specific geohash
public func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
public func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !context.isBlocked($0.key) }
return map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
}
/// Refresh the visible people list
public func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
public func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
/// Stop the periodic refresh timer
public func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
public func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
public func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
}
}
}
@@ -0,0 +1,471 @@
//
// MessageFormattingEngine.swift
// bitchat
//
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
// This is free and unencumbered software released into the public domain.
//
import Foundation
import SwiftUI
// MARK: - Formatting Context Protocol
/// Protocol defining the context needed for message formatting.
/// Implemented by ChatViewModel to provide runtime state.
@MainActor
protocol MessageFormattingContext: AnyObject {
/// The user's current nickname
var nickname: String { get }
/// Determines if a message was sent by the current user
func isSelfMessage(_ message: BitchatMessage) -> Bool
/// Gets the color for a message's sender
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
/// Resolves a peer ID to a clickable URL
func peerURL(for peerID: PeerID) -> URL?
}
// MARK: - Formatting Engine
/// Handles rich text formatting for chat messages.
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
final class MessageFormattingEngine {
// MARK: - Precompiled Regexes
/// Precompiled regex patterns for message content parsing
enum Patterns {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
}
// MARK: - Match Types
/// Types of matches found in message content
enum MatchType: String {
case hashtag
case mention
case url
case cashu
case lightning
case bolt11
case lnurl
}
/// A match found in message content
struct ContentMatch {
let range: NSRange
let type: MatchType
}
// MARK: - Public API
/// Formats a message with rich text styling
@MainActor
static func formatMessage(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
// Check cache first
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
return cached
}
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
// Format system messages differently
if message.sender == "system" {
result = formatSystemMessage(message, isDark: isDark)
} else {
// Format sender header
result = formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
// Format content
let contentResult = formatContent(
message.content,
baseColor: baseColor,
isSelf: isSelf,
isMentioned: message.mentions?.contains(context.nickname) ?? false
)
result.append(contentResult)
// Add timestamp
result.append(formatTimestamp(message.formattedTimestamp))
}
// Cache the result
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
/// Formats just the message header (sender portion)
@MainActor
static func formatHeader(
_ message: BitchatMessage,
context: MessageFormattingContext,
colorScheme: ColorScheme
) -> AttributedString {
let isDark = colorScheme == .dark
let isSelf = context.isSelfMessage(message)
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
return AttributedString(message.sender).mergingAttributes(style)
}
return formatSenderHeader(
message: message,
baseColor: baseColor,
isSelf: isSelf,
context: context
)
}
/// Extracts mentions from message content
static func extractMentions(from content: String) -> [String] {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = Patterns.mention.matches(in: content, options: [], range: range)
return matches.compactMap { match -> String? in
guard match.numberOfRanges > 1 else { return nil }
let captureRange = match.range(at: 1)
guard let swiftRange = Range(captureRange, in: content) else { return nil }
return String(content[swiftRange])
}
}
/// Checks if content contains a Cashu token
static func containsCashuToken(_ content: String) -> Bool {
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
}
// MARK: - Private Helpers
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
var result = AttributedString()
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
return result
}
@MainActor
private static func formatSenderHeader(
message: BitchatMessage,
baseColor: Color,
isSelf: Bool,
context: MessageFormattingContext
) -> AttributedString {
var result = AttributedString()
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
// Make sender clickable
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
senderStyle.link = url
}
// Build: "<@baseName#suffix> "
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
return result
}
private static func formatContent(
_ content: String,
baseColor: Color,
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
// Find all matches
let matches = findAllMatches(in: content)
// Build formatted content
var result = AttributedString()
var lastEnd = content.startIndex
for match in matches {
guard let swiftRange = Range(match.range, in: content) else { continue }
// Add text before match
if lastEnd < swiftRange.lowerBound {
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
// Add styled match
let matchText = String(content[swiftRange])
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
lastEnd = swiftRange.upperBound
}
// Add remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
}
return result
}
private static func findAllMatches(in content: String) -> [ContentMatch] {
let nsContent = content as NSString
let nsLen = nsContent.length
let fullRange = NSRange(location: 0, length: nsLen)
// Quick hints to avoid unnecessary regex work
let hasMentions = content.contains("@")
let hasHashtags = content.contains("#")
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashu = content.lowercased().contains("cashu")
// Collect matches
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
// Build mention ranges for overlap checking
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ r: NSRange) -> Bool {
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
}
func isStandaloneHashtag(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content) else { return false }
if swiftRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: swiftRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}
func attachedToMention(_ r: NSRange) -> Bool {
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
var i = content.index(before: swiftRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
return false
}
var allMatches: [ContentMatch] = []
// Add hashtags (excluding those attached to mentions)
for match in hashtagMatches {
let range = match.range(at: 0)
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
allMatches.append(ContentMatch(range: range, type: .hashtag))
}
}
// Add mentions
for match in mentionMatches {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
}
// Add URLs
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append(ContentMatch(range: match.range, type: .url))
}
// Add Cashu tokens
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
}
// Add Lightning scheme URLs
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
}
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ r: NSRange) -> Bool {
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
}
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
}
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
}
// Sort by position
return allMatches.sorted { $0.range.location < $1.range.location }
}
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
return AttributedString(content).mergingAttributes(style)
}
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
guard !text.isEmpty else { return AttributedString() }
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
style.font = style.font?.bold()
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
var style = AttributeContainer()
switch type {
case .mention:
// Split optional '#abcd' suffix
let (baseName, suffix) = text.splitSuffix()
var result = AttributedString()
var mentionStyle = AttributeContainer()
mentionStyle.foregroundColor = .blue
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
if !suffix.isEmpty {
var suffixStyle = mentionStyle
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
return result
case .hashtag:
style.foregroundColor = .purple
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
case .url:
style.foregroundColor = .blue
style.font = .bitchatSystem(size: 14, design: .monospaced)
style.underlineStyle = .single
if let url = URL(string: text) {
style.link = url
}
case .cashu:
style.foregroundColor = .green
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.green.opacity(0.1)
case .lightning, .bolt11, .lnurl:
style.foregroundColor = .yellow
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.backgroundColor = Color.yellow.opacity(0.1)
}
return AttributedString(text).mergingAttributes(style)
}
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
let text = AttributedString(" [\(timestamp)]")
var style = AttributeContainer()
style.foregroundColor = Color.gray.opacity(0.5)
style.font = .bitchatSystem(size: 10, design: .monospaced)
return text.mergingAttributes(style)
}
}
+83 -92
View File
@@ -92,7 +92,7 @@ import UniformTypeIdentifiers
/// Manages the application state and business logic for BitChat. /// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services, /// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events. /// implementing the BitchatDelegate protocol to handle network events.
final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider { final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
// Precompiled regexes and detectors reused across formatting // Precompiled regexes and detectors reused across formatting
private enum Regexes { private enum Regexes {
static let hashtag: NSRegularExpression = { static let hashtag: NSRegularExpression = {
@@ -419,10 +419,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:] private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participants (per geohash: pubkey -> lastSeen) // Geohash participant tracker
private var geoParticipants: [String: [String: Date]] = [:] let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
@Published private(set) var geohashPeople: [GeoPerson] = []
private var geoParticipantsTimer: Timer? = nil
// Participants who indicated they teleported (by tag in their events) // Participants who indicated they teleported (by tag in their events)
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex @Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open) // Sampling subscriptions for multiple geohashes (when channel sheet is open)
@@ -526,6 +524,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Wire up dependencies // Wire up dependencies
self.commandProcessor.chatViewModel = self self.commandProcessor.chatViewModel = self
self.participantTracker.configure(context: self)
// Subscribe to privateChatManager changes to trigger UI updates // Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.objectWillChange privateChatManager.objectWillChange
@@ -533,6 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self?.objectWillChange.send() self?.objectWillChange.send()
} }
.store(in: &cancellables) .store(in: &cancellables)
// Subscribe to participantTracker changes to trigger UI updates
participantTracker.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
self.commandProcessor.meshService = meshService self.commandProcessor.meshService = meshService
loadNickname() loadNickname()
@@ -873,7 +879,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
return return
} }
// Ensure participant decay timer is running // Ensure participant decay timer is running
startGeoParticipantsTimer() participantTracker.startRefreshTimer()
// Unsubscribe + resubscribe // Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID) NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral( let filter = NostrFilter.geohashEphemeral(
@@ -932,7 +938,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey // Update participants last-seen for this pubkey
recordGeoParticipant(pubkeyHex: event.pubkey) participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Track teleported tag (only our format ["t","teleport"]) for icon state // Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in let hasTeleportTag = event.tags.contains(where: { tag in
@@ -1479,7 +1485,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
// Track ourselves as active participant // Track ourselves as active participant
recordGeoParticipant(pubkeyHex: identity.publicKeyHex) participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session)
@@ -1514,8 +1520,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
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)
} }
stopGeoParticipantsTimer() participantTracker.stopRefreshTimer()
geohashPeople = [] participantTracker.setActiveGeohash(nil)
teleportedGeo.removeAll() teleportedGeo.removeAll()
case .location: case .location:
refreshVisibleMessages(from: channel) refreshVisibleMessages(from: channel)
@@ -1536,16 +1542,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
geoDmSubscriptionID = nil geoDmSubscriptionID = nil
} }
currentGeohash = nil currentGeohash = nil
participantTracker.setActiveGeohash(nil)
// Reset nickname cache for geochat participants // Reset nickname cache for geochat participants
geoNicknames.removeAll() geoNicknames.removeAll()
geohashPeople = []
guard case .location(let ch) = channel else { return } guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash currentGeohash = ch.geohash
participantTracker.setActiveGeohash(ch.geohash)
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported // Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
recordGeoParticipant(pubkeyHex: id.publicKeyHex) participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
let key = id.publicKeyHex.lowercased() let key = id.publicKeyHex.lowercased()
@@ -1559,7 +1566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
let subID = "geo-\(ch.geohash)" let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID geoSubscriptionID = subID
startGeoParticipantsTimer() participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
@@ -1629,7 +1636,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// Update participants last-seen for this pubkey // Update participants last-seen for this pubkey
recordGeoParticipant(pubkeyHex: event.pubkey) participantTracker.recordParticipant(pubkeyHex: event.pubkey)
let senderName = displayNameForNostrPubkey(event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content let content = event.content
@@ -1811,49 +1818,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
// MARK: - Geohash Participants // MARK: - Geohash Participants
struct GeoPerson: Identifiable, Equatable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
}
private func recordGeoParticipant(pubkeyHex: String) {
guard let gh = currentGeohash else { return }
let key = pubkeyHex.lowercased()
var map = geoParticipants[gh] ?? [:]
map[key] = Date()
geoParticipants[gh] = map
refreshGeohashPeople()
}
private func recordGeoParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = geoParticipants[geohash] ?? [:]
map[key] = Date()
geoParticipants[geohash] = map
// Only refresh list if this geohash is currently selected
if currentGeohash == geohash {
refreshGeohashPeople()
}
}
private func refreshGeohashPeople() {
guard let gh = currentGeohash else { geohashPeople = []; return }
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
var map = geoParticipants[gh] ?? [:]
// Prune expired entries
map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
geoParticipants[gh] = map
// Build display list
let people = map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
geohashPeople = people
}
@MainActor @MainActor
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
@@ -1878,33 +1842,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
return false return false
} }
private func startGeoParticipantsTimer() {
stopGeoParticipantsTimer()
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refreshGeohashPeople()
}
}
}
private func stopGeoParticipantsTimer() {
geoParticipantsTimer?.invalidate()
geoParticipantsTimer = nil
}
// MARK: - Public helpers // MARK: - Public helpers
/// Published geohash people list for SwiftUI observation
var geohashPeople: [GeoPerson] {
participantTracker.visiblePeople
}
/// Return the current, pruned, sorted people list for the active geohash without mutating state. /// Return the current, pruned, sorted people list for the active geohash without mutating state.
@MainActor @MainActor
func visibleGeohashPeople() -> [GeoPerson] { func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] } participantTracker.getVisiblePeople()
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
let map = (geoParticipants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen }
return people
} }
/// CommandContextProvider conformance - returns visible geo participants /// CommandContextProvider conformance - returns visible geo participants
@@ -1914,9 +1862,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
/// Returns the current participant count for a specific geohash, using the 5-minute activity window. /// Returns the current participant count for a specific geohash, using the 5-minute activity window.
@MainActor @MainActor
func geohashParticipantCount(for geohash: String) -> Int { func geohashParticipantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) participantTracker.participantCount(for: geohash)
let map = geoParticipants[geohash] ?? [:] }
return map.values.filter { $0 >= cutoff }.count
// MARK: - GeohashParticipantContext Protocol
func displayNameForPubkey(_ pubkeyHex: String) -> String {
displayNameForNostrPubkey(pubkeyHex)
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
} }
// Geohash block helpers // Geohash block helpers
@@ -1930,11 +1886,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
identityManager.setNostrBlocked(hex, isBlocked: true) identityManager.setNostrBlocked(hex, isBlocked: true)
// Remove from participants for all geohashes // Remove from participants for all geohashes
for (gh, var map) in geoParticipants { participantTracker.removeParticipant(pubkeyHex: hex)
map.removeValue(forKey: hex)
geoParticipants[gh] = map
}
refreshGeohashPeople()
// Remove their public messages from current geohash timeline and visible list // Remove their public messages from current geohash timeline and visible list
if let gh = currentGeohash { if let gh = currentGeohash {
@@ -2024,11 +1976,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event // Compute current participant count (5-minute window) BEFORE updating with this event
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let existingCount = participantTracker.participantCount(for: gh)
let existingCount = geoParticipants[gh]?.values.filter { $0 >= cutoff }.count ?? 0
// Update participants for this specific geohash // Update participants for this specific geohash
recordGeoParticipant(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) let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -3229,7 +3180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
// Track ourselves as active participant // Track ourselves as active participant
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
} catch { } catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
self.addSystemMessage( self.addSystemMessage(
@@ -4242,6 +4193,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
return Color(peerSeed: message.sender.lowercased(), isDark: isDark) return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
} }
// MARK: - MessageFormattingContext Protocol
@MainActor
func isSelfMessage(_ message: BitchatMessage) -> Bool {
if let spid = message.senderPeerID {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash {
return cached.identity
}
// Derive and cache
if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
cachedGeohashIdentity = (ch.geohash, identity)
return identity
}
return nil
}()
if let myGeo {
return spid == PeerID(nostr: myGeo.publicKeyHex)
}
}
return spid == meshService.myPeerID
}
// Fallback by nickname
if message.sender == nickname { return true }
if message.sender.hasPrefix(nickname + "#") { return true }
return false
}
@MainActor
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
return peerColor(for: message, isDark: isDark)
}
@MainActor
func peerURL(for peerID: PeerID) -> URL? {
return URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
}
// Public helpers for views to color peers consistently in lists // Public helpers for views to color peers consistently in lists
@MainActor @MainActor
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
@@ -0,0 +1,293 @@
//
// GeohashParticipantTrackerTests.swift
// bitchatTests
//
// Tests for GeohashParticipantTracker.
// This is free and unencumbered software released into the public domain.
//
import Testing
import Foundation
@testable import bitchat
/// Mock context for testing
@MainActor
final class MockParticipantContext: GeohashParticipantContext {
var blockedPubkeys: Set<String> = []
var nicknameMap: [String: String] = [:]
var selfPubkey: String?
func displayNameForPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() {
return "me#\(suffix)"
}
if let nick = nicknameMap[pubkeyHex.lowercased()] {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
blockedPubkeys.contains(pubkeyHexLowercased.lowercased())
}
}
@MainActor
struct GeohashParticipantTrackerTests {
// MARK: - Basic Recording Tests
@Test func recordParticipant_addsToActiveGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
#expect(tracker.participantCount(for: "abc123") == 1)
}
@Test func recordParticipant_noActiveGeohash_noOp() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
// No active geohash set
tracker.recordParticipant(pubkeyHex: "deadbeef1234")
// Should not throw or crash
#expect(tracker.participantCount(for: "abc123") == 0)
}
@Test func recordParticipant_specificGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
#expect(tracker.participantCount(for: "geo1") == 1)
#expect(tracker.participantCount(for: "geo2") == 1)
}
@Test func recordParticipant_updatesLastSeen() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Small delay and record again
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Should still count as 1 participant (updated, not duplicated)
#expect(tracker.participantCount(for: "abc123") == 1)
}
@Test func recordParticipant_lowercasesPubkey() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "DEADBEEF")
tracker.recordParticipant(pubkeyHex: "deadbeef")
// Should be treated as same participant
#expect(tracker.participantCount(for: "abc123") == 1)
}
// MARK: - Visible People Tests
@Test func getVisiblePeople_returnsActiveGeohashParticipants() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2")
let people = tracker.getVisiblePeople()
#expect(people.count == 2)
}
@Test func getVisiblePeople_excludesBlockedParticipants() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
context.blockedPubkeys = ["pubkey2"]
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2")
let people = tracker.getVisiblePeople()
#expect(people.count == 1)
#expect(people.first?.id == "pubkey1")
}
@Test func getVisiblePeople_usesDisplayNameFromContext() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
context.nicknameMap = ["pubkey1234": "alice"]
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1234")
let people = tracker.getVisiblePeople()
#expect(people.count == 1)
#expect(people.first?.displayName == "alice#1234")
}
@Test func getVisiblePeople_sortedByLastSeen() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "older")
try? await Task.sleep(nanoseconds: 10_000_000) // 10ms
tracker.recordParticipant(pubkeyHex: "newer")
let people = tracker.getVisiblePeople()
#expect(people.count == 2)
#expect(people.first?.id == "newer")
#expect(people.last?.id == "older")
}
@Test func getVisiblePeople_emptyWhenNoActiveGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
let people = tracker.getVisiblePeople()
#expect(people.isEmpty)
}
// MARK: - Activity Cutoff Tests
@Test func participantCount_excludesExpiredEntries() async {
// Use a very short cutoff for testing
let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
// Should be counted immediately
#expect(tracker.participantCount(for: "abc123") == 1)
// Wait for expiry
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Should be expired now
#expect(tracker.participantCount(for: "abc123") == 0)
}
// MARK: - Remove Participant Tests
@Test func removeParticipant_removesFromAllGeohashes() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1")
tracker.removeParticipant(pubkeyHex: "pubkey1")
#expect(tracker.participantCount(for: "geo1") == 1)
#expect(tracker.participantCount(for: "geo2") == 0)
}
// MARK: - Clear Tests
@Test func clear_removesAllData() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other")
tracker.clear()
#expect(tracker.participantCount(for: "abc123") == 0)
#expect(tracker.participantCount(for: "other") == 0)
#expect(tracker.visiblePeople.isEmpty)
}
@Test func clearGeohash_removesOnlySpecificGeohash() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2")
tracker.clear(geohash: "geo1")
#expect(tracker.participantCount(for: "geo1") == 0)
#expect(tracker.participantCount(for: "geo2") == 1)
}
// MARK: - Set Active Geohash Tests
@Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
tracker.setActiveGeohash("abc123")
tracker.recordParticipant(pubkeyHex: "pubkey1")
#expect(!tracker.visiblePeople.isEmpty)
tracker.setActiveGeohash(nil)
#expect(tracker.visiblePeople.isEmpty)
}
@Test func setActiveGeohash_refreshesVisiblePeople() async {
let tracker = GeohashParticipantTracker()
let context = MockParticipantContext()
tracker.configure(context: context)
// Pre-populate a geohash
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123")
// Set it as active
tracker.setActiveGeohash("abc123")
#expect(tracker.visiblePeople.count == 1)
}
// MARK: - GeoPerson Tests
@Test func geoPerson_identifiable() async {
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date())
let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date())
#expect(person1.id == person2.id)
#expect(person1.id != person3.id)
}
@Test func geoPerson_equatable() async {
let date = Date()
let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date)
#expect(person1 == person2)
}
}
@@ -0,0 +1,223 @@
//
// MessageFormattingEngineTests.swift
// bitchatTests
//
// Tests for MessageFormattingEngine regex patterns and utility functions.
// This is free and unencumbered software released into the public domain.
//
import Testing
import Foundation
import SwiftUI
@testable import bitchat
struct MessageFormattingEngineTests {
// MARK: - Mention Extraction Tests
@Test func extractMentions_singleMention() {
let content = "Hello @alice how are you?"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions == ["alice"])
}
@Test func extractMentions_multipleMentions() {
let content = "@alice and @bob are chatting with @charlie"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions.count == 3)
#expect(mentions.contains("alice"))
#expect(mentions.contains("bob"))
#expect(mentions.contains("charlie"))
}
@Test func extractMentions_mentionWithSuffix() {
let content = "Hey @alice#a1b2 check this out"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions == ["alice#a1b2"])
}
@Test func extractMentions_noMentions() {
let content = "Just a regular message with no mentions"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions.isEmpty)
}
@Test func extractMentions_unicodeNickname() {
let content = "Hello @日本語 and @émile"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions.count == 2)
#expect(mentions.contains("日本語"))
#expect(mentions.contains("émile"))
}
@Test func extractMentions_mentionWithUnderscore() {
let content = "Thanks @user_name_123"
let mentions = MessageFormattingEngine.extractMentions(from: content)
#expect(mentions == ["user_name_123"])
}
@Test func extractMentions_emailNotCaptured() {
// Email addresses should not be captured as mentions
let content = "Contact me at test@example.com"
let mentions = MessageFormattingEngine.extractMentions(from: content)
// The regex will capture "example" after @ in email - this is expected behavior
// as the regex doesn't distinguish email addresses
#expect(mentions.count == 1)
}
// MARK: - Cashu Token Detection Tests
@Test func containsCashuToken_validTokenA() {
let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
#expect(MessageFormattingEngine.containsCashuToken(content))
}
@Test func containsCashuToken_validTokenB() {
let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i"
#expect(MessageFormattingEngine.containsCashuToken(content))
}
@Test func containsCashuToken_noToken() {
let content = "Just a regular message about cashews"
#expect(!MessageFormattingEngine.containsCashuToken(content))
}
@Test func containsCashuToken_tooShort() {
let content = "Invalid: cashuAshort"
#expect(!MessageFormattingEngine.containsCashuToken(content))
}
// MARK: - Regex Pattern Tests
@Test func hashtagPattern_standaloneHashtag() {
let content = "#bitcoin is great"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
@Test func hashtagPattern_multipleHashtags() {
let content = "#bitcoin #lightning #nostr"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
#expect(matches.count == 3)
}
@Test func hashtagPattern_hashInMiddleOfWord() {
let content = "test#notahashtag"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range)
// This will match because the regex doesn't check for word boundaries
#expect(matches.count == 1)
}
@Test func bolt11Pattern_mainnet() {
let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
@Test func bolt11Pattern_testnet() {
let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
@Test func lnurlPattern_valid() {
let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
@Test func lightningSchemePattern_valid() {
let content = "Click: lightning:lnbc10u1example"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
@Test func cashuPattern_valid() {
let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0="
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range)
#expect(matches.count == 1)
}
// MARK: - URL Detection Tests
@Test func linkDetector_httpURL() {
let content = "Check out http://example.com"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
#expect(matches.count == 1)
}
@Test func linkDetector_httpsURL() {
let content = "Visit https://example.com/path?query=value"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
#expect(matches.count == 1)
}
@Test func linkDetector_multipleURLs() {
let content = "See https://a.com and http://b.com"
let nsContent = content as NSString
let range = NSRange(location: 0, length: nsContent.length)
let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? []
#expect(matches.count == 2)
}
// MARK: - String Extension Tests
@Test func splitSuffix_withSuffix() {
let name = "alice#a1b2"
let (base, suffix) = name.splitSuffix()
#expect(base == "alice")
#expect(suffix == "#a1b2")
}
@Test func splitSuffix_withoutSuffix() {
let name = "alice"
let (base, suffix) = name.splitSuffix()
#expect(base == "alice")
#expect(suffix == "")
}
@Test func splitSuffix_withAtPrefix() {
let name = "@alice#a1b2"
let (base, suffix) = name.splitSuffix()
#expect(base == "alice")
#expect(suffix == "#a1b2")
}
@Test func hasVeryLongToken_noLongToken() {
let content = "Short words only here"
#expect(!content.hasVeryLongToken(threshold: 50))
}
@Test func hasVeryLongToken_withLongToken() {
let longToken = String(repeating: "a", count: 100)
let content = "Here is a \(longToken) token"
#expect(content.hasVeryLongToken(threshold: 50))
}
@Test func hasVeryLongToken_exactThreshold() {
let exactToken = String(repeating: "a", count: 50)
let content = "Token: \(exactToken)"
// Exactly at threshold DOES trigger (uses >= comparison)
#expect(content.hasVeryLongToken(threshold: 50))
}
}