mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:05:20 +00:00
* Bound public rate-limit buckets against attacker-keyed growth. Keep the NIP-13 PoW sender bypass, skip content-bucket minting on sender reject, and evict idle/oldest entries at a hard cap. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop Cashu-looking text from skipping long-message guards. Oversized public content always collapses and takes the plain formatting path so remote tokens cannot force layout/regex DoS. Co-authored-by: Cursor <cursoragent@cursor.com> * Cap teleported geohash participant markers. Bound the set with FIFO eviction, clear it on channel switch, and prune markers that leave the visible participant list. Co-authored-by: Cursor <cursoragent@cursor.com> * Bound untrusted Nostr relay frames and event tags. Reject oversized inbound messages before JSON parse, cap tag arrays/values at decode, and stop logging raw tag contents. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail soft when Noise handshake state is unexpectedly missing. Replace the initiator startHandshake force unwrap with a guard that throws invalidState instead of crashing. Co-authored-by: Cursor <cursoragent@cursor.com> * Cap geohash nickname cache from remote Nostr events. FIFO-evict at capacity, clear on channel switch, and prune nicknames that leave the visible participant list. Co-authored-by: Cursor <cursoragent@cursor.com> * Avoid overlapping exclusive access in the rate limiter. Make bucket helpers static so inout dictionary updates do not conflict with a mutating call on self. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix rate-limiter tests for mutating allow under #expect. Call allow outside the macro so Swift Testing does not capture an immutable copy of the struct. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unused WebSocket data helper; reset rate limiter on panic wipe. dataWithinInboundLimit replaced the unbounded path, and panic clear should not leave public intake buckets behind. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
502 lines
24 KiB
Swift
502 lines
24 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class ChatMessageFormatter {
|
|
typealias Patterns = MessageFormattingEngine.Patterns
|
|
|
|
private unowned let viewModel: ChatViewModel
|
|
private let meshPalette = MinimalDistancePalette(config: .mesh)
|
|
private let nostrPalette = MinimalDistancePalette(config: .nostr)
|
|
|
|
init(viewModel: ChatViewModel) {
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
|
|
let design = theme.bodyFontDesign
|
|
let isSelf: Bool = {
|
|
if let spid = message.senderPeerID {
|
|
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
|
|
let myGeo: NostrIdentity? = {
|
|
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
|
|
return cached.identity
|
|
}
|
|
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
|
|
return identity
|
|
}
|
|
return nil
|
|
}()
|
|
if let myGeo {
|
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
|
}
|
|
}
|
|
return spid == viewModel.meshService.myPeerID
|
|
}
|
|
if message.sender == viewModel.nickname { return true }
|
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
|
return false
|
|
}()
|
|
|
|
let isDark = colorScheme == .dark
|
|
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
|
|
return cachedText
|
|
}
|
|
|
|
var result = AttributedString()
|
|
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
|
|
|
if message.sender != "system" {
|
|
let (baseName, suffix) = message.sender.splitSuffix()
|
|
var senderStyle = AttributeContainer()
|
|
senderStyle.foregroundColor = baseColor
|
|
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
|
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design)
|
|
if let spid = message.senderPeerID,
|
|
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
|
|
senderStyle.link = url
|
|
}
|
|
|
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
|
if !suffix.isEmpty {
|
|
var suffixStyle = senderStyle
|
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
}
|
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
|
|
|
let content = message.content
|
|
let nsContent = content as NSString
|
|
let nsLen = nsContent.length
|
|
|
|
if content.isOversizedForRichFormatting() {
|
|
var plainStyle = AttributeContainer()
|
|
plainStyle.foregroundColor = baseColor
|
|
plainStyle.font = isSelf
|
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
|
: .bitchatSystem(size: 14, design: design)
|
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
|
} else {
|
|
let hashtagRegex = Patterns.hashtag
|
|
let mentionRegex = Patterns.mention
|
|
let cashuRegex = Patterns.cashu
|
|
let bolt11Regex = Patterns.bolt11
|
|
let lnurlRegex = Patterns.lnurl
|
|
let lightningSchemeRegex = Patterns.lightningScheme
|
|
let detector = Patterns.linkDetector
|
|
let hasMentionsHint = content.contains("@")
|
|
let hasHashtagsHint = content.contains("#")
|
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
|
let hasCashuHint = content.lowercased().contains("cashu")
|
|
|
|
let hashtagMatches = hasHashtagsHint
|
|
? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
let mentionMatches = hasMentionsHint
|
|
? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
let urlMatches = hasURLHint
|
|
? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? [])
|
|
: []
|
|
let cashuMatches = hasCashuHint
|
|
? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
let lightningMatches = hasLightningHint
|
|
? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
let bolt11Matches = hasLightningHint
|
|
? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
let lnurlMatches = hasLightningHint
|
|
? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
|
: []
|
|
|
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
|
func overlapsMention(_ range: NSRange) -> Bool {
|
|
for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func attachedToMention(_ range: NSRange) -> Bool {
|
|
if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex {
|
|
var index = content.index(before: swiftRange.lowerBound)
|
|
while true {
|
|
let character = content[index]
|
|
if character.isWhitespace || character.isNewline { break }
|
|
if character == "@" { return true }
|
|
if index == content.startIndex { break }
|
|
index = content.index(before: index)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isStandaloneHashtag(_ range: NSRange) -> Bool {
|
|
guard let swiftRange = Range(range, in: content) else { return false }
|
|
if swiftRange.lowerBound == content.startIndex { return true }
|
|
let previous = content.index(before: swiftRange.lowerBound)
|
|
return content[previous].isWhitespace || content[previous].isNewline
|
|
}
|
|
|
|
var allMatches: [(range: NSRange, type: String)] = []
|
|
for match in hashtagMatches
|
|
where !overlapsMention(match.range(at: 0))
|
|
&& !attachedToMention(match.range(at: 0))
|
|
&& isStandaloneHashtag(match.range(at: 0)) {
|
|
allMatches.append((match.range(at: 0), "hashtag"))
|
|
}
|
|
for match in mentionMatches {
|
|
allMatches.append((match.range(at: 0), "mention"))
|
|
}
|
|
for match in urlMatches where !overlapsMention(match.range) {
|
|
allMatches.append((match.range, "url"))
|
|
}
|
|
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
|
allMatches.append((match.range(at: 0), "cashu"))
|
|
}
|
|
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
|
allMatches.append((match.range(at: 0), "lightning"))
|
|
}
|
|
|
|
let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) }
|
|
func overlapsOccupied(_ range: NSRange) -> Bool {
|
|
for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
for match in bolt11Matches
|
|
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
allMatches.append((match.range(at: 0), "bolt11"))
|
|
}
|
|
for match in lnurlMatches
|
|
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
allMatches.append((match.range(at: 0), "lnurl"))
|
|
}
|
|
allMatches.sort { $0.range.location < $1.range.location }
|
|
|
|
var lastEnd = content.startIndex
|
|
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
|
|
|
for (range, type) in allMatches {
|
|
guard let swiftRange = Range(range, in: content) else { continue }
|
|
|
|
if lastEnd < swiftRange.lowerBound {
|
|
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
|
if !beforeText.isEmpty {
|
|
var beforeStyle = AttributeContainer()
|
|
beforeStyle.foregroundColor = baseColor
|
|
beforeStyle.font = isSelf
|
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
|
: .bitchatSystem(size: 14, design: design)
|
|
if isMentioned {
|
|
beforeStyle.font = beforeStyle.font?.bold()
|
|
}
|
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
}
|
|
}
|
|
|
|
let matchText = String(content[swiftRange])
|
|
if type == "mention" {
|
|
let (mentionBase, mentionSuffix) = matchText.splitSuffix()
|
|
let mySuffix: String? = {
|
|
if case .location(let channel) = viewModel.activeChannel,
|
|
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
return String(identity.publicKeyHex.suffix(4))
|
|
}
|
|
return String(viewModel.meshService.myPeerID.id.prefix(4))
|
|
}()
|
|
let isMentionToMe: Bool = {
|
|
if mentionBase == viewModel.nickname {
|
|
if let mySuffix, !mentionSuffix.isEmpty {
|
|
return mentionSuffix == "#\(mySuffix)"
|
|
}
|
|
return mentionSuffix.isEmpty
|
|
}
|
|
return false
|
|
}()
|
|
|
|
var mentionStyle = AttributeContainer()
|
|
mentionStyle.font = .bitchatSystem(
|
|
size: 14,
|
|
weight: isSelf ? .bold : .semibold,
|
|
design: design
|
|
)
|
|
let mentionColor: Color = isMentionToMe ? .orange : baseColor
|
|
mentionStyle.foregroundColor = mentionColor
|
|
let at = "@"
|
|
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
|
|
result.append(AttributedString(mentionBase).mergingAttributes(mentionStyle))
|
|
if !mentionSuffix.isEmpty {
|
|
var lightStyle = mentionStyle
|
|
lightStyle.foregroundColor = mentionColor.opacity(0.6)
|
|
result.append(AttributedString(mentionSuffix).mergingAttributes(lightStyle))
|
|
}
|
|
} else if type == "hashtag" {
|
|
let token = String(matchText.dropFirst()).lowercased()
|
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
|
let attachedToMentionToken: Bool = {
|
|
if swiftRange.lowerBound > content.startIndex {
|
|
var index = content.index(before: swiftRange.lowerBound)
|
|
while true {
|
|
let character = content[index]
|
|
if character.isWhitespace || character.isNewline { break }
|
|
if character == "@" { return true }
|
|
if index == content.startIndex { break }
|
|
index = content.index(before: index)
|
|
}
|
|
}
|
|
return false
|
|
}()
|
|
let standalone: Bool = {
|
|
if swiftRange.lowerBound == content.startIndex { return true }
|
|
let previous = content.index(before: swiftRange.lowerBound)
|
|
return content[previous].isWhitespace || content[previous].isNewline
|
|
}()
|
|
|
|
var tagStyle = AttributeContainer()
|
|
tagStyle.font = isSelf
|
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
|
: .bitchatSystem(size: 14, design: design)
|
|
tagStyle.foregroundColor = baseColor
|
|
if isGeohash && !attachedToMentionToken && standalone,
|
|
let url = URL(string: "bitchat://geohash/\(token)") {
|
|
tagStyle.link = url
|
|
tagStyle.underlineStyle = .single
|
|
}
|
|
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
|
|
} else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" {
|
|
var spacer = AttributeContainer()
|
|
spacer.foregroundColor = baseColor
|
|
spacer.font = isSelf
|
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
|
: .bitchatSystem(size: 14, design: design)
|
|
result.append(AttributedString(" ").mergingAttributes(spacer))
|
|
} else {
|
|
var matchStyle = AttributeContainer()
|
|
matchStyle.font = .bitchatSystem(
|
|
size: 14,
|
|
weight: isSelf ? .bold : .semibold,
|
|
design: design
|
|
)
|
|
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))
|
|
}
|
|
|
|
if lastEnd < swiftRange.upperBound {
|
|
lastEnd = swiftRange.upperBound
|
|
}
|
|
}
|
|
|
|
if lastEnd < content.endIndex {
|
|
let remainingText = String(content[lastEnd...])
|
|
var remainingStyle = AttributeContainer()
|
|
remainingStyle.foregroundColor = baseColor
|
|
remainingStyle.font = isSelf
|
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
|
: .bitchatSystem(size: 14, design: design)
|
|
if isMentioned {
|
|
remainingStyle.font = remainingStyle.font?.bold()
|
|
}
|
|
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
|
}
|
|
}
|
|
|
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
timestampStyle.font = .bitchatSystem(size: 10, design: design)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
} else {
|
|
var contentStyle = AttributeContainer()
|
|
contentStyle.foregroundColor = Color.gray
|
|
let content = AttributedString("* \(message.content) *")
|
|
contentStyle.font = .bitchatSystem(size: 12, design: design).italic()
|
|
result.append(content.mergingAttributes(contentStyle))
|
|
|
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
|
timestampStyle.font = .bitchatSystem(size: 10, design: design)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
}
|
|
|
|
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
|
|
return result
|
|
}
|
|
|
|
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
|
|
let design = theme.bodyFontDesign
|
|
let isSelf: Bool = {
|
|
if let spid = message.senderPeerID {
|
|
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
|
|
let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
|
}
|
|
return spid == viewModel.meshService.myPeerID
|
|
}
|
|
if message.sender == viewModel.nickname { return true }
|
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
|
return false
|
|
}()
|
|
|
|
let isDark = colorScheme == .dark
|
|
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
|
|
|
if message.sender == "system" {
|
|
var style = AttributeContainer()
|
|
style.foregroundColor = baseColor
|
|
style.font = .bitchatSystem(size: 14, weight: .medium, design: design)
|
|
return AttributedString(message.sender).mergingAttributes(style)
|
|
}
|
|
|
|
var result = AttributedString()
|
|
let (baseName, suffix) = message.sender.splitSuffix()
|
|
var senderStyle = AttributeContainer()
|
|
senderStyle.foregroundColor = baseColor
|
|
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design)
|
|
if let spid = message.senderPeerID,
|
|
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
|
|
senderStyle.link = url
|
|
}
|
|
|
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
|
if !suffix.isEmpty {
|
|
var suffixStyle = senderStyle
|
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
}
|
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
|
return result
|
|
}
|
|
|
|
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
|
if let spid = message.senderPeerID {
|
|
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
|
|
let myGeo: NostrIdentity? = {
|
|
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
|
|
return cached.identity
|
|
}
|
|
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
|
|
return identity
|
|
}
|
|
return nil
|
|
}()
|
|
if let myGeo {
|
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
|
}
|
|
}
|
|
return spid == viewModel.meshService.myPeerID
|
|
}
|
|
if message.sender == viewModel.nickname { return true }
|
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
|
return false
|
|
}
|
|
|
|
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
|
peerColor(for: message, isDark: isDark)
|
|
}
|
|
|
|
func peerURL(for peerID: PeerID) -> URL? {
|
|
URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
|
|
}
|
|
|
|
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
|
getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
|
|
}
|
|
|
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
|
getPeerPaletteColor(for: peerID, isDark: isDark)
|
|
}
|
|
}
|
|
|
|
private extension ChatMessageFormatter {
|
|
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
|
if let spid = message.senderPeerID {
|
|
if spid.isGeoChat || spid.isGeoDM {
|
|
let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
|
|
return getNostrPaletteColor(for: full, isDark: isDark)
|
|
} else if spid.id.count == 16 {
|
|
return getPeerPaletteColor(for: spid, isDark: isDark)
|
|
} else {
|
|
return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)
|
|
}
|
|
}
|
|
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
|
}
|
|
|
|
func meshSeed(for peerID: PeerID) -> String {
|
|
if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() {
|
|
return "noise:" + full
|
|
}
|
|
return peerID.id.lowercased()
|
|
}
|
|
|
|
func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
|
if peerID == viewModel.meshService.myPeerID {
|
|
return .orange
|
|
}
|
|
|
|
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
|
|
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
|
|
return color
|
|
}
|
|
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
|
}
|
|
|
|
func currentMeshPaletteSeeds() -> [String: String] {
|
|
let myID = viewModel.meshService.myPeerID
|
|
var seeds: [String: String] = [:]
|
|
for peer in viewModel.allPeers where peer.peerID != myID {
|
|
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
|
|
}
|
|
return seeds
|
|
}
|
|
|
|
func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
|
let myHex = currentGeohashIdentityHex()
|
|
if let myHex, pubkeyHexLowercased == myHex {
|
|
return .orange
|
|
}
|
|
|
|
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
|
|
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
|
|
return color
|
|
}
|
|
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
|
}
|
|
|
|
func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
|
|
var seeds: [String: String] = [:]
|
|
let excluded = myHex ?? ""
|
|
for person in viewModel.visibleGeohashPeople() where person.id != excluded {
|
|
seeds[person.id] = "nostr:" + person.id
|
|
}
|
|
return seeds
|
|
}
|
|
|
|
func currentGeohashIdentityHex() -> String? {
|
|
if case .location(let channel) = viewModel.activeChannel,
|
|
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
|
return identity.publicKeyHex.lowercased()
|
|
}
|
|
return nil
|
|
}
|
|
}
|