From 0260798712f0e6022dce301a0c5418106efad915 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 24 Aug 2025 02:05:02 +0200 Subject: [PATCH] Fix/UI (#500) * Remove link previews: link URLs inline\n\n- Delete LinkPreviewView and all references\n- Strip preview usage from ContentView\n- Make detected URLs tappable via .link attribute\n- Add MessageTextHelpers for tokens/length checks\n- Wire helpers into iOS/macOS targets * Peer list UX: match message colors, use map pin icon, stable ordering\n\n- Color list names and icons with same peer color as messages\n- Default icon mappin; teleported uses face.dashed (iOS)\n- Geohash: move teleported peers to bottom\n- Maintain stable order: append new peers, remove missing\n- Mesh list: replace state icons with colored mappin/person * Fix peer list build errors: remove ambiguous Set type, avoid guard in ViewBuilder, remove duplicate bindings, and simplify person lookup * Peer list: remove onChange expressions to avoid type-check issues; rely on ObservedObject updates * Peer lists: simplify view structure to satisfy SwiftUI type checker (replace Group with VStack, flatten body) * Peer lists: move ordering mutations to onAppear/onChange, keep body pure; fix result builder errors * Fix: remove trailing modifiers outside if/else to avoid ViewBuilder type issues * Color parity: fix seed cache to include color scheme; normalize Nostr seeds; switch to mappin.circle icon * Peer colors: ensure exact match with message colors\n\n- Mesh list uses same seed logic as messages (getNoiseKeyForShortID fallback)\n- Color cache keyed by theme; normalize nostr seeds\n- Icons: use mappin.and.ellipse (default) and face.dashed for teleported\n- Teleport tag: accept "teleported" in addition to "teleport" and presence events * Fix geohash color mismatch: populate nostrKeyMapping before building messages in resubscribe; also honor t,teleport tag for teleported state * Message actions: username tap opens sheet via clickable AttributedString; message tap inserts @mention; rename to 'direct message' * Cashu UX: suppress 'Show more' when message contains a Cashu token (chips handle rendering) * Cashu detection: broaden regex (allow '.') + shorter min length; avoid long-text fallback when Cashu present so chips render and no full blob * Geohash levels: rename region->province, country->region; update labels, precision, UI, manager mapping, tests; add Codable backward-compat for renamed cases * Fix braces in LocationChannel.swift: close enum before extension to satisfy file-scope declarations * Geohash links: make #geohash tappable and open that channel (bitchat://geohash/); handle in ContentView.onOpenURL with validation and selection * Underline tappable #geohash mentions for clarity * Geochat: sanitize timeline on channel switch to remove any blank-content entries (prevents blank rows) * Xcode project: sync sources after UI changes (remove LinkPreview, add helpers, update build refs) --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 20 +- bitchat/Protocols/LocationChannel.swift | 52 +- bitchat/Services/LocationChannelManager.swift | 10 +- bitchat/ViewModels/ChatViewModel.swift | 93 +++- bitchat/Views/ContentView.swift | 81 ++- bitchat/Views/GeohashPeopleList.swift | 94 ++-- bitchat/Views/LinkPreviewView.swift | 498 ------------------ bitchat/Views/LocationChannelsSheet.swift | 6 +- bitchat/Views/MeshPeerList.swift | 74 ++- bitchat/Views/MessageTextHelpers.swift | 72 +++ bitchatTests/LocationChannelsTests.swift | 4 +- 11 files changed, 336 insertions(+), 668 deletions(-) delete mode 100644 bitchat/Views/LinkPreviewView.swift create mode 100644 bitchat/Views/MessageTextHelpers.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 8b51b4fc..c9b5eb8c 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -60,7 +60,6 @@ 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; }; - 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; }; 37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; }; 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; }; 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; }; @@ -82,7 +81,6 @@ 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; }; - 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; }; 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; }; @@ -106,6 +104,8 @@ A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; + AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; + AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; @@ -233,9 +233,9 @@ 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = ""; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; - 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = ""; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -427,7 +427,7 @@ 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, - 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */, + AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */, ); path = Views; sourceTree = ""; @@ -729,7 +729,6 @@ 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */, 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, - 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, @@ -756,6 +755,7 @@ 049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */, D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, + AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -782,7 +782,6 @@ B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */, EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, - 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, @@ -809,6 +808,7 @@ 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */, EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, + AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -960,7 +960,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -991,7 +991,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1046,7 +1046,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1260,7 +1260,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index fa2ff23a..ac58946d 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -5,8 +5,8 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case block case neighborhood case city - case region - case country + case province // previously .region + case region // previously .country /// Geohash length used for this level. var precision: Int { @@ -14,9 +14,9 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case .block: return 7 case .neighborhood: return 6 case .city: return 5 - case .region: return 4 - case .country: return 2 - } + case .province: return 4 + case .region: return 2 + } } var displayName: String { @@ -24,8 +24,48 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { case .block: return "Block" case .neighborhood: return "Neighborhood" case .city: return "City" + case .province: return "Province" case .region: return "Region" - case .country: return "Country" + } +} +} +// Backward-compatible Codable for renamed cases +extension GeohashChannelLevel { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let raw = try? container.decode(String.self) { + switch raw { + case "block": self = .block + case "neighborhood": self = .neighborhood + case "city": self = .city + case "region": self = .province // old "region" maps to new .province + case "country": self = .region // old "country" maps to new .region + case "province": self = .province + default: + self = .block + } + } else if let precision = try? container.decode(Int.self) { + switch precision { + case 7: self = .block + case 6: self = .neighborhood + case 5: self = .city + case 4: self = .province + case 0...3: self = .region + default: self = .block + } + } else { + self = .block + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .block: try container.encode("block") + case .neighborhood: try container.encode("neighborhood") + case .city: try container.encode("city") + case .province: try container.encode("province") + case .region: try container.encode("region") } } } diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 322a21a8..3bffc320 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -223,15 +223,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] { var dict: [GeohashChannelLevel: String] = [:] - // Country + // Region (country) if let country = pm.country, !country.isEmpty { - dict[.country] = country + dict[.region] = country } - // Region (state/province or county) + // Province (state/province or county) if let admin = pm.administrativeArea, !admin.isEmpty { - dict[.region] = admin + dict[.province] = admin } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { - dict[.region] = subAdmin + dict[.province] = subAdmin } // City (locality) if let locality = pm.locality, !locality.isEmpty { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 64548e70..141c1d6e 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -590,8 +590,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let nick = nickTag[1] self.geoNicknames[event.pubkey.lowercased()] = nick } + // Store mapping for geohash sender IDs used in messages (ensures consistent colors) + let key16 = "nostr_" + String(event.pubkey.prefix(16)) + self.nostrKeyMapping[key16] = event.pubkey + let key8 = "nostr:" + String(event.pubkey.prefix(8)) + self.nostrKeyMapping[key8] = event.pubkey + // Update participants last-seen for this pubkey self.recordGeoParticipant(pubkeyHex: event.pubkey) + + // Track teleported tag (only our format ["t","teleport"]) for icon state + let hasTeleportTag = event.tags.contains(where: { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + }) + if hasTeleportTag { + let key = event.pubkey.lowercased() + Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) } + } let senderName = self.displayNameForNostrPubkey(event.pubkey) let content = event.content let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -1169,7 +1184,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { geohashPeople = [] teleportedGeo.removeAll() case .location(let ch): - messages = geoTimelines[ch.geohash] ?? [] + // Sanitize existing timeline (filter any prior empty-content entries) + var arr = geoTimelines[ch.geohash] ?? [] + let before = arr.count + arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if arr.count != before { geoTimelines[ch.geohash] = arr } + messages = arr } // Unsubscribe previous if let sub = geoSubscriptionID { @@ -1214,20 +1234,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: SecureLogger.session, level: .debug) - // Track teleport tag for participants - // Detect teleport tag robustly: accept ["t","teleport"], ["t"], ["teleport"], or boolean-like values - let hasTeleportTag: Bool = { - for tag in event.tags { - guard let key = tag.first?.lowercased() else { continue } - if key == "t" || key == "teleport" { return true } - // Some clients may encode as ["t","1"] or ["t","true"] - if key == "t", tag.dropFirst().contains(where: { v in - let lv = v.lowercased() - return lv == "teleport" || lv == "1" || lv == "true" - }) { return true } - } - return false - }() + // Track teleport tag for participants – only our format ["t", "teleport"] + let hasTeleportTag: Bool = event.tags.contains(where: { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + }) if hasTeleportTag { let key = event.pubkey.lowercased() Task { @MainActor in @@ -1262,7 +1272,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let senderName = self.displayNameForNostrPubkey(event.pubkey) let content = event.content // If this is a teleport presence event (no content), don't add to timeline - if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport", + if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"), content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } @@ -2839,6 +2849,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Bold the user's own nickname let fontWeight: Font.Weight = isSelf ? .bold : .medium senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced) + // Make sender clickable: encode senderPeerID into a custom URL + if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { + senderStyle.link = url + } // Prefix "<@" result.append(AttributedString("<@").mergingAttributes(senderStyle)) @@ -2856,8 +2870,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Process content with hashtags and mentions let content = message.content - // For extremely long content, render as plain text to avoid heavy regex/layout work - if content.count > 4000 || content.hasVeryLongToken(threshold: 1024) { + // For extremely long content, render as plain text to avoid heavy regex/layout work, + // unless the content includes Cashu tokens we want to chip-render below + let containsCashuEarly: Bool = { + let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" + if let rx = try? NSRegularExpression(pattern: pattern, options: []) { + return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 + } + return false + }() + if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { var plainStyle = AttributeContainer() plainStyle.foregroundColor = baseColor plainStyle.font = isSelf @@ -2868,8 +2890,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let hashtagPattern = "#([a-zA-Z0-9_]+)" // Allow optional '#abcd' suffix in mentions let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" - // Cashu token detector: cashuA/cashuB + long base64url - let cashuPattern = "\\bcashu[AB][A-Za-z0-9_-]{60,}\\b" + // Cashu token detector: cashuA/cashuB + long base64url; allow '.' and shorter variants + let cashuPattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" // Lightning invoices and links let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b" let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b" @@ -2990,12 +3012,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } else { // Style non-mention matches if type == "hashtag" { - // Do NOT special-style hashtags: render like normal content (no blue, no underline) + // If the hashtag is a valid geohash, make it tappable (bitchat://geohash/) + let token = String(matchText.dropFirst()).lowercased() + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) } var tagStyle = AttributeContainer() tagStyle.font = isSelf ? .system(size: 14, weight: .bold, design: .monospaced) : .system(size: 14, design: .monospaced) tagStyle.foregroundColor = baseColor + if isGeohash, let url = URL(string: "bitchat://geohash/\(token)") { + tagStyle.link = url + tagStyle.underlineStyle = .single + } result.append(AttributedString(matchText).mergingAttributes(tagStyle)) } else if type == "cashu" { // Skip inline token; a styled chip is rendered below the message @@ -3015,12 +3044,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { : .system(size: 14, design: .monospaced) result.append(AttributedString(" ").mergingAttributes(spacer)) } else { - // Keep URL styling (blue + underline for non-self, orange for self) + // Keep URL styling and make it tappable via .link attribute var matchStyle = AttributeContainer() matchStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) if type == "url" { matchStyle.foregroundColor = isSelf ? .orange : .blue matchStyle.underlineStyle = .single + if let url = URL(string: matchText) { + matchStyle.link = url + } } result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } @@ -3327,7 +3359,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { - if let cached = peerColorCache[seed] { return cached } + let cacheKey = seed + (isDark ? "|dark" : "|light") + if let cached = peerColorCache[cacheKey] { return cached } var hue = Double(djb2(seed) % 360) / 360.0 // Avoid orange (~30°) reserved for self let orange = 30.0 / 360.0 @@ -3335,7 +3368,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let saturation: Double = isDark ? 0.80 : 0.70 let brightness: Double = isDark ? 0.75 : 0.45 let c = Color(hue: hue, saturation: saturation, brightness: brightness) - peerColorCache[seed] = c + peerColorCache[cacheKey] = c return c } @@ -3344,7 +3377,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var seed: String if let spid = message.senderPeerID { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { - let full = nostrKeyMapping[spid]?.lowercased() ?? spid.lowercased() + // Normalize to the bare short id, then prefer full mapping when available + let bare: String = { + if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) } + if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) } + return spid + }() + let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased() seed = "nostr:" + full } else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { seed = "noise:" + full @@ -3365,8 +3404,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color { - if let peer = unifiedPeerService.getPeer(by: peerID) { - let full = peer.noisePublicKey.hexEncodedString().lowercased() + // Mirror message coloring: prefer stable full noise key mapping when available, else short ID + if let full = getNoiseKeyForShortID(peerID)?.lowercased() { return colorForPeerSeed("noise:" + full, isDark: isDark) } return colorForPeerSeed(peerID.lowercased(), isDark: isDark) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0aeabf49..94d77c19 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -25,28 +25,7 @@ struct PeerDisplayData: Identifiable { let isMutualFavorite: Bool } -// MARK: - Lazy Link Preview - -// Lazy loading wrapper for link previews -struct LazyLinkPreviewView: View { - let url: URL - let title: String? - @State private var isVisible = false - - var body: some View { - Group { - if isVisible { - LinkPreviewView(url: url, title: title) - } else { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.1)) - .frame(height: 80) - } - } - .frame(height: 80) - .onAppear { isVisible = true } - } -} +// (Link previews removed; URLs are now clickable inline) // MARK: - Main Content View @@ -214,7 +193,7 @@ struct ContentView: View { } } - Button("private message") { + Button("direct message") { if let peerID = selectedMessageSenderID { #if os(iOS) if peerID.hasPrefix("nostr:") { @@ -333,7 +312,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 0) { // Single text view for natural wrapping - let isLong = message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512) + let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty let isExpanded = expandedMessageIDs.contains(message.id) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) @@ -349,7 +328,7 @@ struct ContentView: View { } // Expand/Collapse for very long messages - if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) { + if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty { let isExpanded = expandedMessageIDs.contains(message.id) Button(isExpanded ? "Show less" : "Show more") { if isExpanded { expandedMessageIDs.remove(message.id) } @@ -360,17 +339,7 @@ struct ContentView: View { .padding(.top, 4) } - // Check for plain URLs - let urls = message.content.extractURLs() - if !urls.isEmpty { - ForEach(urls.prefix(2).indices, id: \.self) { index in - let urlInfo = urls[index] - LazyLinkPreviewView(url: urlInfo.url, title: nil) - .padding(.top, 3) - .padding(.horizontal, 1) - .id("\(message.id)-\(urlInfo.url.absoluteString)") - } - } + // Link previews removed: URLs appear inline and are tappable // Render payment chips (Lightning / Cashu) with rounded background let lightningLinks = message.content.extractLightningLinks() @@ -462,11 +431,11 @@ struct ContentView: View { } .contentShape(Rectangle()) .onTapGesture { - // Only show actions for messages from other users (not system or self) - if message.sender != "system" && message.sender != viewModel.nickname { - selectedMessageSender = message.sender - selectedMessageSenderID = message.senderPeerID - showMessageActions = true + // Tap on message body: insert @mention for this sender + if message.sender != "system" { + let name = message.sender + messageText = "@\(name) " + isTextFieldFocused = true } } .contextMenu { @@ -487,6 +456,36 @@ struct ContentView: View { .padding(.vertical, 4) } .background(backgroundColor) + .onOpenURL { url in + guard url.scheme == "bitchat", url.host == "user" else { return } + let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let peerID = id.removingPercentEncoding ?? id + selectedMessageSenderID = peerID + selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender + showMessageActions = true + } + .onOpenURL { url in + guard url.scheme == "bitchat", url.host == "geohash" else { return } + let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } + #if os(iOS) + func levelForLength(_ len: Int) -> GeohashChannelLevel { + switch len { + case 0...2: return .region + case 3...4: return .province + case 5: return .city + case 6: return .neighborhood + case 7: return .block + default: return .block + } + } + let level = levelForLength(gh.count) + let ch = GeohashChannel(level: level, geohash: gh) + LocationChannelManager.shared.markTeleported(for: gh, true) + LocationChannelManager.shared.select(ChannelID.location(ch)) + #endif + } .onTapGesture(count: 3) { // Triple-tap to clear current chat viewModel.sendMessage("/clear") diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index b7b4aea3..09af0e5a 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -7,54 +7,63 @@ struct GeohashPeopleList: View { let secondaryTextColor: Color let onTapPerson: () -> Void @Environment(\.colorScheme) var colorScheme + @State private var orderedIDs: [String] = [] var body: some View { - Group { - if viewModel.visibleGeohashPeople().isEmpty { + if viewModel.visibleGeohashPeople().isEmpty { + VStack(alignment: .leading, spacing: 0) { Text("nobody around...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) .padding(.top, 12) - } else { - let myHex: String? = { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { - return id.publicKeyHex.lowercased() - } - return nil - }() - let ordered = viewModel.visibleGeohashPeople().sorted { a, b in - if let me = myHex { - if a.id == me && b.id != me { return true } - if b.id == me && a.id != me { return false } - } - return a.lastSeen > b.lastSeen + } + } else { + let myHex: String? = { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + return id.publicKeyHex.lowercased() } - let firstID = ordered.first?.id - ForEach(ordered) { person in + return nil + }() + let people = viewModel.visibleGeohashPeople() + let currentIDs = people.map { $0.id } + + #if os(iOS) + let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) + let isTeleportedID: (String) -> Bool = { id in + if teleportedSet.contains(id.lowercased()) { return true } + if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } + return false + } + #else + let isTeleportedID: (String) -> Bool = { _ in false } + #endif + + let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } + let nonTele = displayIDs.filter { !isTeleportedID($0) } + let tele = displayIDs.filter { isTeleportedID($0) } + let finalOrder: [String] = nonTele + tele + let firstID = finalOrder.first + let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) }) + + VStack(alignment: .leading, spacing: 0) { + ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in + let person = personByID[pid]! HStack(spacing: 4) { - let convKey = "nostr_" + String(person.id.prefix(16)) - if viewModel.unreadPrivateMessages.contains(convKey) { - Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange) - } else { - // For the local user, use a different face icon when teleported - let isMe = (person.id == myHex) - #if os(iOS) - // Consider either the per-session tag (for any peer) or the manager flag for self - let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) - #else - let teleported = false - #endif - let icon = teleported ? "face.dashed" : "face.smiling" - let rowColor: Color = isMe ? .orange : textColor - Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor) - } - let (base, suffix) = splitSuffix(from: person.displayName) - let isMe = person.id == myHex + let isMe = (person.id == myHex) + #if os(iOS) + let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) + #else + let teleported = false + #endif + let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) + let rowColor: Color = isMe ? .orange : assignedColor + Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor) + + let (base, suffix) = splitSuffix(from: person.displayName) HStack(spacing: 0) { - let rowColor: Color = isMe ? .orange : assignedColor Text(base) .font(.system(size: 14, design: .monospaced)) .fontWeight(isMe ? .bold : .regular) @@ -71,7 +80,6 @@ struct GeohashPeopleList: View { .foregroundColor(rowColor) } } - // Blocked indicator for geohash users if let me = myHex, person.id != me { if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { Image(systemName: "nosign") @@ -106,6 +114,16 @@ struct GeohashPeopleList: View { } } } + // Seed and update order outside result builder + .onAppear { + orderedIDs = currentIDs + } + .onChange(of: currentIDs) { ids in + var newOrder = orderedIDs + newOrder.removeAll { !ids.contains($0) } + for id in ids where !newOrder.contains(id) { newOrder.append(id) } + if newOrder != orderedIDs { orderedIDs = newOrder } + } } } } diff --git a/bitchat/Views/LinkPreviewView.swift b/bitchat/Views/LinkPreviewView.swift deleted file mode 100644 index e1300bd7..00000000 --- a/bitchat/Views/LinkPreviewView.swift +++ /dev/null @@ -1,498 +0,0 @@ -// -// LinkPreviewView.swift -// bitchat -// -// This is free and unencumbered software released into the public domain. -// For more information, see -// - -import SwiftUI -#if os(iOS) -import LinkPresentation -import UIKit -#endif - -// MARK: - Link Metadata Cache - -/// Cache for link metadata to prevent repeated network requests -private class LinkMetadataCache { - static let shared = LinkMetadataCache() - - #if os(iOS) - private let cache = NSCache() - private let imageCache = NSCache() - #endif - private let queue = DispatchQueue(label: "chat.bitchat.linkmetadata.cache", attributes: .concurrent) - - private init() { - #if os(iOS) - cache.countLimit = 100 // Keep metadata for up to 100 URLs - imageCache.countLimit = 50 // Keep images for up to 50 URLs - imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB limit for images - #endif - } - - #if os(iOS) - class CachedMetadata { - let metadata: LPLinkMetadata? - let title: String? - let host: String? - let error: Error? - let timestamp: Date - - init(metadata: LPLinkMetadata? = nil, title: String? = nil, host: String? = nil, error: Error? = nil) { - self.metadata = metadata - self.title = title - self.host = host - self.error = error - self.timestamp = Date() - } - } - - func getCachedMetadata(for url: URL) -> (metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?)? { - return queue.sync { - guard let cached = cache.object(forKey: url as NSURL) else { return nil } - - // Check if cache is older than 24 hours - if Date().timeIntervalSince(cached.timestamp) > 86400 { - cache.removeObject(forKey: url as NSURL) - imageCache.removeObject(forKey: url as NSURL) - return nil - } - - let image = imageCache.object(forKey: url as NSURL) - return (cached.metadata, cached.title, cached.host, image) - } - } - - func cacheMetadata(_ metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?, for url: URL) { - queue.async(flags: .barrier) { - let cached = CachedMetadata(metadata: metadata, title: title, host: host) - self.cache.setObject(cached, forKey: url as NSURL) - - if let image = image { - let cost = Int(image.size.width * image.size.height * 4) // Approximate memory usage - self.imageCache.setObject(image, forKey: url as NSURL, cost: cost) - } - } - } - - func cacheError(_ error: Error, for url: URL) { - queue.async(flags: .barrier) { - let cached = CachedMetadata(error: error) - self.cache.setObject(cached, forKey: url as NSURL) - } - } - #endif - - func clearCache() { - queue.async(flags: .barrier) { - #if os(iOS) - self.cache.removeAllObjects() - self.imageCache.removeAllObjects() - #endif - } - } -} - -// MARK: - Link Preview View - -struct LinkPreviewView: View { - let url: URL - let title: String? - @Environment(\.colorScheme) var colorScheme - #if os(iOS) - @State private var metadata: LPLinkMetadata? - @State private var cachedTitle: String? - @State private var cachedHost: String? - @State private var isLoading = false - #endif - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - - private var backgroundColor: Color { - colorScheme == .dark ? Color.black : Color.white - } - - private var borderColor: Color { - textColor.opacity(0.3) - } - - var body: some View { - // Always use our custom compact view for consistent appearance - compactLinkView - .onAppear { - loadFromCacheOrFetch() - } - } - - #if os(iOS) - @State private var previewImage: UIImage? = nil - #endif - - private var compactLinkView: some View { - Button(action: { - #if os(iOS) - UIApplication.shared.open(url) - #else - NSWorkspace.shared.open(url) - #endif - }) { - HStack(spacing: 12) { - // Preview image or icon - Group { - #if os(iOS) - if let image = previewImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 60, height: 60) - .clipped() - .cornerRadius(8) - } else { - // Favicon or default icon - RoundedRectangle(cornerRadius: 8) - .fill(Color.blue.opacity(0.1)) - .frame(width: 60, height: 60) - .overlay( - Image(systemName: "link") - .font(.system(size: 24)) - .foregroundColor(Color.blue) - ) - } - #else - RoundedRectangle(cornerRadius: 8) - .fill(Color.blue.opacity(0.1)) - .frame(width: 60, height: 60) - .overlay( - Image(systemName: "link") - .font(.system(size: 24)) - .foregroundColor(Color.blue) - ) - #endif - } - - VStack(alignment: .leading, spacing: 4) { - // Title - #if os(iOS) - Text(cachedTitle ?? metadata?.title ?? title ?? url.host ?? "Link") - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - .lineLimit(2) - .multilineTextAlignment(.leading) - #else - Text(title ?? url.host ?? "Link") - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - .lineLimit(2) - .multilineTextAlignment(.leading) - #endif - - // Host - #if os(iOS) - Text(cachedHost ?? url.host ?? url.absoluteString) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(textColor.opacity(0.6)) - .lineLimit(1) - #else - Text(url.host ?? url.absoluteString) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(textColor.opacity(0.6)) - .lineLimit(1) - #endif - } - - Spacer() - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(borderColor, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - - private var simpleLinkView: some View { - Button(action: { - #if os(iOS) - UIApplication.shared.open(url) - #else - NSWorkspace.shared.open(url) - #endif - }) { - HStack(spacing: 12) { - // Link icon - Image(systemName: "link.circle.fill") - .font(.system(size: 32)) - .foregroundColor(Color.blue.opacity(0.8)) - .frame(width: 40, height: 40) - - VStack(alignment: .leading, spacing: 4) { - // Title - Text(title ?? url.host ?? "Link") - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - .lineLimit(2) - .multilineTextAlignment(.leading) - - // URL - Text(url.absoluteString) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(Color.blue) - .lineLimit(1) - .truncationMode(.middle) - } - - Spacer() - - // Arrow indicator - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(textColor.opacity(0.5)) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(borderColor, lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - - private func loadFromCacheOrFetch() { - #if os(iOS) - // Check if we already have data in state - guard metadata == nil && !isLoading else { - return - } - - // Check cache first - if let cached = LinkMetadataCache.shared.getCachedMetadata(for: url) { - // print("🔗 LinkPreviewView: Using CACHED metadata for: \(url.absoluteString)") - self.metadata = cached.metadata - self.cachedTitle = cached.title ?? cached.metadata?.title - self.cachedHost = cached.host ?? url.host - self.previewImage = cached.image - return - } - - // Not in cache, fetch it - // print("🔗 LinkPreviewView: FETCHING metadata for: \(url.absoluteString)") - isLoading = true - - let provider = LPMetadataProvider() - provider.startFetchingMetadata(for: url) { fetchedMetadata, error in - DispatchQueue.main.async { - self.isLoading = false - - if let error = error { - // Check if it's an ATS error for subresources (non-critical) - let errorString = error.localizedDescription.lowercased() - let isATSError = errorString.contains("app transport security") || - errorString.contains("secure connection") - - if !isATSError { - // Only log non-ATS errors - // print("🔗 LinkPreviewView: Error fetching metadata: \(error)") - } - - // Still try to show basic preview with URL info - self.cachedTitle = self.title ?? self.url.host - self.cachedHost = self.url.host - - // Cache even failed attempts to avoid repeated fetches - LinkMetadataCache.shared.cacheMetadata( - nil, - title: self.cachedTitle, - host: self.cachedHost, - image: nil, - for: self.url - ) - return - } - - if let fetchedMetadata = fetchedMetadata { - // Use the fetched metadata, or create new with our title - if let title = self.title, !title.isEmpty { - fetchedMetadata.title = title - } - self.metadata = fetchedMetadata - self.cachedTitle = fetchedMetadata.title ?? self.title - self.cachedHost = self.url.host - - // Try to extract image - if let imageProvider = fetchedMetadata.imageProvider { - imageProvider.loadObject(ofClass: UIImage.self) { image, error in - DispatchQueue.main.async { - if let image = image as? UIImage { - self.previewImage = image - // Cache everything including the image - LinkMetadataCache.shared.cacheMetadata( - fetchedMetadata, - title: self.cachedTitle, - host: self.cachedHost, - image: image, - for: self.url - ) - } else { - // Cache without image - LinkMetadataCache.shared.cacheMetadata( - fetchedMetadata, - title: self.cachedTitle, - host: self.cachedHost, - image: nil, - for: self.url - ) - } - } - } - } else { - // No image, cache what we have - LinkMetadataCache.shared.cacheMetadata( - fetchedMetadata, - title: self.cachedTitle, - host: self.cachedHost, - image: nil, - for: self.url - ) - } - } - } - } - #endif - } -} - -#if os(iOS) -// UIViewRepresentable wrapper for LPLinkView -struct LinkPreview: UIViewRepresentable { - let metadata: LPLinkMetadata - - func makeUIView(context: Context) -> UIView { - let containerView = UIView() - containerView.backgroundColor = .clear - - let linkView = LPLinkView(metadata: metadata) - linkView.isUserInteractionEnabled = false // We handle taps at the SwiftUI level - linkView.translatesAutoresizingMaskIntoConstraints = false - - containerView.addSubview(linkView) - NSLayoutConstraint.activate([ - linkView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - linkView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - linkView.topAnchor.constraint(equalTo: containerView.topAnchor), - linkView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) - ]) - - return containerView - } - - func updateUIView(_ uiView: UIView, context: Context) { - // Update if needed - } -} -#endif - -// Helper to extract URLs from text -extension String { - func extractURLs() -> [(url: URL, range: Range)] { - var urls: [(URL, Range)] = [] - - // Check for plain URLs - let types: NSTextCheckingResult.CheckingType = .link - if let detector = try? NSDataDetector(types: types.rawValue) { - let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count)) - for match in matches { - if let range = Range(match.range, in: self), - let url = match.url { - urls.append((url, range)) - } - } - } - - return urls - } - - // Detect if there is an extremely long token (no whitespace/newlines) that could break layout - func hasVeryLongToken(threshold: Int) -> Bool { - var current = 0 - for ch in self { - if ch.isWhitespace || ch.isNewline { - if current >= threshold { return true } - current = 0 - } else { - current += 1 - if current >= threshold { return true } - } - } - return current >= threshold - } - - // Extract up to `max` Cashu tokens (cashuA/cashuB) - func extractCashuTokens(max: Int = 3) -> [String] { - let pattern = "\\bcashu[AB][A-Za-z0-9_-]{60,}\\b" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } - let ns = self as NSString - let range = NSRange(location: 0, length: ns.length) - var found: [String] = [] - for m in regex.matches(in: self, options: [], range: range) { - if m.numberOfRanges > 0 { - let token = ns.substring(with: m.range(at: 0)) - found.append(token) - if found.count >= max { break } - } - } - return found - } - - // Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning: - func extractLightningLinks(max: Int = 3) -> [String] { - var results: [String] = [] - let ns = self as NSString - let full = NSRange(location: 0, length: ns.length) - // lightning: scheme - if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) { - for m in schemeRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append(s) - if results.count >= max { return results } - } - } - // BOLT11 - if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) { - for m in boltRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append("lightning:\(s)") - if results.count >= max { return results } - } - } - // LNURL bech32 - if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) { - for m in lnurlRx.matches(in: self, options: [], range: full) { - let s = ns.substring(with: m.range(at: 0)) - results.append("lightning:\(s)") - if results.count >= max { return results } - } - } - return results - } -} - -#Preview { - VStack { - LinkPreviewView(url: URL(string: "https://example.com")!, title: "Example Website") - .padding() - } -} diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index da88c0a2..29467355 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -298,8 +298,8 @@ struct LocationChannelsSheet: View { private func levelForLength(_ len: Int) -> GeohashChannelLevel { switch len { - case 0...2: return .country - case 3...4: return .region + case 0...2: return .region + case 3...4: return .province case 5: return .city case 6: return .neighborhood case 7: return .block @@ -382,7 +382,7 @@ extension LocationChannelsSheet { private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { switch level { - case .country: + case .region: return "" default: return "~" diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 911761f5..dc67f958 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -9,60 +9,48 @@ struct MeshPeerList: View { let onShowFingerprint: (String) -> Void @Environment(\.colorScheme) var colorScheme + @State private var orderedIDs: [String] = [] + var body: some View { - Group { - if viewModel.allPeers.isEmpty { + if viewModel.allPeers.isEmpty { + VStack(alignment: .leading, spacing: 0) { Text("nobody around...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) .padding(.top, 12) - } else { - let myPeerID = viewModel.meshService.myPeerID - let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in - let isMe = peer.id == myPeerID - let hasUnread = viewModel.hasUnreadMessages(for: peer.id) - let enc = viewModel.getEncryptionStatus(for: peer.id) - return (peer, isMe, hasUnread, enc) - } - let peers = mapped.sorted { lhs, rhs in - let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false - let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false - if lFav != rFav { return lFav } - let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname - let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname - return lhsName < rhsName - } + } + } else { + let myPeerID = viewModel.meshService.myPeerID + let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in + let isMe = peer.id == myPeerID + let hasUnread = viewModel.hasUnreadMessages(for: peer.id) + let enc = viewModel.getEncryptionStatus(for: peer.id) + return (peer, isMe, hasUnread, enc) + } + // Stable visual order without mutating state here + let currentIDs = mapped.map { $0.peer.id } + let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } + let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in + mapped.first(where: { $0.peer.id == id }) + } + VStack(alignment: .leading, spacing: 0) { ForEach(0.. Bool { + var current = 0 + for ch in self { + if ch.isWhitespace || ch.isNewline { + if current >= threshold { return true } + current = 0 + } else { + current += 1 + if current >= threshold { return true } + } + } + return current >= threshold + } + + // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. + func extractCashuTokens(max: Int = 3) -> [String] { + let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + let ns = self as NSString + let range = NSRange(location: 0, length: ns.length) + var found: [String] = [] + for m in regex.matches(in: self, options: [], range: range) { + if m.numberOfRanges > 0 { + let token = ns.substring(with: m.range(at: 0)) + found.append(token) + if found.count >= max { break } + } + } + return found + } + + // Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning: + func extractLightningLinks(max: Int = 3) -> [String] { + var results: [String] = [] + let ns = self as NSString + let full = NSRange(location: 0, length: ns.length) + // lightning: scheme + if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) { + for m in schemeRx.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append(s) + if results.count >= max { return results } + } + } + // BOLT11 + if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) { + for m in boltRx.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append("lightning:\(s)") + if results.count >= max { return results } + } + } + // LNURL bech32 + if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) { + for m in lnurlRx.matches(in: self, options: [], range: full) { + let s = ns.substring(with: m.range(at: 0)) + results.append("lightning:\(s)") + if results.count >= max { return results } + } + } + return results + } +} diff --git a/bitchatTests/LocationChannelsTests.swift b/bitchatTests/LocationChannelsTests.swift index 3f0a0a8c..cd7b22d6 100644 --- a/bitchatTests/LocationChannelsTests.swift +++ b/bitchatTests/LocationChannelsTests.swift @@ -9,8 +9,8 @@ final class LocationChannelsTests: XCTestCase { let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision) let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision) let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision) - let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) - let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision) + let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision) + let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) XCTAssertEqual(block.count, 7) XCTAssertEqual(neighborhood.count, 6)