mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
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/<gh>); 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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
|
||||
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = "<group>"; };
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
|
||||
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -427,7 +427,7 @@
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */,
|
||||
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<gh>)
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
//
|
||||
// LinkPreviewView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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<NSURL, CachedMetadata>()
|
||||
private let imageCache = NSCache<NSURL, UIImage>()
|
||||
#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<String.Index>)] {
|
||||
var urls: [(URL, Range<String.Index>)] = []
|
||||
|
||||
// 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:<payload>
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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 "~"
|
||||
|
||||
@@ -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..<peers.count, id: \.self) { idx in
|
||||
let item = peers[idx]
|
||||
let peer = item.peer
|
||||
let isMe = item.isMe
|
||||
let hasUnread = item.hasUnread
|
||||
HStack(spacing: 4) {
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
if isMe {
|
||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
|
||||
} else if hasUnread {
|
||||
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
|
||||
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
|
||||
} else {
|
||||
switch peer.connectionState {
|
||||
case .bluetoothConnected:
|
||||
Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 10)).foregroundColor(textColor)
|
||||
case .nostrAvailable:
|
||||
Image(systemName: "globe").font(.system(size: 10)).foregroundColor(.purple)
|
||||
case .offline:
|
||||
if peer.favoriteStatus?.isFavorite ?? false {
|
||||
Image(systemName: "moon.fill").font(.system(size: 10)).foregroundColor(.gray)
|
||||
} else {
|
||||
Image(systemName: "person").font(.system(size: 10)).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
|
||||
}
|
||||
|
||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||
let (base, suffix) = splitSuffix(from: displayName)
|
||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
||||
let baseColor = isMe ? Color.orange : assigned
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -75,7 +63,6 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked indicator
|
||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||
Image(systemName: "nosign")
|
||||
.font(.system(size: 10))
|
||||
@@ -86,7 +73,7 @@ struct MeshPeerList: View {
|
||||
if let icon = item.enc.icon, !isMe {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red))
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -108,6 +95,17 @@ struct MeshPeerList: View {
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
.onAppear {
|
||||
let currentIDs = mapped.map { $0.peer.id }
|
||||
orderedIDs = currentIDs
|
||||
}
|
||||
.onChange(of: mapped.map { $0.peer.id }) { 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// MessageTextHelpers.swift
|
||||
// Shared text parsing helpers for message rendering.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
// 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). 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:<payload>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user