Feature/location channels (#459)

* Require signed announces; add Ed25519 key/signature/timestamp TLVs and verify

- Protocol: AnnouncementPacket now requires ed25519PublicKey (0x03), announceSignature (0x04), and announceTimestamp (0x05). Encoder emits, decoder requires; unknown TLVs still tolerated.
- NoiseEncryptionService: add canonical announce sign/verify helpers using context 'bitchat-announce-v1'.
- BLEService: sign Announce, include TLVs; on receive verify (±5 min skew) and ignore unverified; store ed25519 key and isVerifiedNickname in PeerInfo.
- Preserve 8-byte on-wire IDs to keep BLE headers small; no per-message bloat.

* Fix Data.append usage for timestamp bytes in Packets and NoiseEncryptionService (use append(contentsOf:) on UnsafeRawBufferPointer)

* BLEService: fix non-optional announce fields and unwrap signature result; enforce required verification path

* Enforce verified-only public messages; append peerID suffix on nickname collisions in handleMessage

* Suffix duplicate nicknames with peerID prefix in snapshots and getPeerNicknames; ensures lists and messages disambiguate 'jack' vs 'jack'

* Include self nickname in collision detection: suffix remote 'jack' when it matches our nickname in lists and public chat

* UI labels: remove space before '#abcd' suffix in names (public chat, peer lists, snapshots)

* Style '#abcd' suffix light gray:
- Public chat: color suffix in sender via AttributedString segments
- People list: split name and color suffix segment; add helper splitNameSuffix()

* Debounce 'bitchatters nearby' notification: add 60s grace before reset when mesh goes empty; cancel reset when peers return

* Lighten '#abcd' suffix: use Color.secondary.opacity(0.6) in public chat sender and People list

* Toolbar peers indicator: use blue when Bluetooth peers present (was default text color)

* Toolbar peers indicator: use grey when zero peers (was red)

* Toolbar peers indicator: use system grey (Color.secondary) when zero peers, not green

* Fix crash: mutate peripherals dict on BLE queue (not main). Move scan restart to BLE queue as well.

* Reduce connect timeout churn: back off peripherals that time out (15s), increase timeout to 8s, skip non-connectable adverts, and demote timeout log to debug; clean backoff map periodically

* Mentions: support '@name#abcd' disambiguation; filter hashtag/URL styling inside mentions; deliver notifications only to the specified suffixed target when collisions exist

* Fix string interpolation in mention log (escape sequence)

* Mentions: parse '@name#abcd' in sender/receiver; render mention suffix grey (not orange/blue/underlined); ensure receiver notifications trigger for suffixed tokens

* Mentions: include own suffixed token 'name#<myIDprefix>' in valid tokens so '@name#abcd' to self is recognized and notified

* Public actions: show own system message by processing own public messages (remove early-return). Private /hug: add local system message for sender's chat.

* Grammar: change local '/hug' message to past tense ('you hugged/slapped'). Notifications: only fire 'bitchatters nearby' on rising-edge; remove 5-min re-fire and increase empty reset grace to 10m.

* Public /hug echo: add local system message so sender sees action immediately (no reliance on echo)

* Fix visibility: expose addPublicSystemMessage on ChatViewModel and use it from CommandProcessor

* Implement iOS Location Channels (geohash public chats)

- Domain: add ChannelID, GeohashChannel(Level), lightweight geohash encoder
- Identity: derive per-geohash Nostr keys (HMAC-SHA256 device seed)
- Nostr: kind 20000 + #g tag, nickname tag (n), filter helper
- iOS: LocationChannelManager (permissions, one-shot location), Info.plist string
- UI: toolbar # badge (#mesh or #<level>), sheet (irc style, full-row taps, settings link)
- VM: subscribe/send for active channel, clear timeline on switch, nickname cache
- Emotes: route public via Nostr/mesh without echo, local system confirmation, emojis restored
- Haptics: detect hugs/slaps for nickname#abcd and trigger on receiver
- Rendering: remove hashtag/mention formatting (plain monospaced)

Note: iOS-only; macOS unaffected.

* Lifecycle and persistence: resubscribe on foreground/relay connect, cap dedup set, persist mesh timeline.

- ChatViewModel: resubscribe geohash on app foreground and relay reconnect
- Add processed Nostr events cap (2k) with eviction
- Persist mesh public messages in meshTimeline; hydrate on switching to #mesh
- Local geochat messages use nostr: senderPeerID for classification
- Tests: fix JSON contains assertion for #g tag

* Fix Swift 6 concurrency warnings: remove closure-based NC observer, wrap relay reconnect resubscribe in Task @MainActor, resubscribe in appDidBecomeActive selector.

* Location Channels polish: live geohash refresh while sheet open; keep selection stable; toolbar shows #<geohash>; sheet labels show human level + #geohash.

* Add per-geohash in-memory timelines and cap private chats to 1337.

- ChatViewModel: persist geohash messages in geoTimelines[geohash] with cap; hydrate timeline on channel switch
- PrivateChatManager: trim per-peer chats to 1337 on send/receive
- Toolbar badge: prevent wrapping with lineLimit(1)/truncation

* Toolbar badge layout + brand color; Teleport custom geohash; Live refresh uses startUpdatingLocation with significant distance.

- Toolbar: right-justify geohash, head truncation, use brand green
- Sheet: add #custom geohash textfield + join; keep minimal IRClike style
- Manager: startUpdatingLocation()/stopUpdatingLocation() with distanceFilter=250 while sheet is open

* Location sheet live updates: reduce distanceFilter to 21m for more frequent geohash refreshes while open.

* Toolbar badge spacing/width; Sheet: rename to #geohash and button 'teleport'.

- Move # label closer to peer count and widen to avoid truncation
- Change custom field placeholder to '#geohash' and action button to 'teleport' with monospaced font

* Sheet polish: style 'teleport' button with subtle background, disable until valid; prefix '#' label and placeholder 'geohash'; center + style 'remove location permission' and hide separators for these rows.  Toolbar: adjust spacing/width for # label.

* Sheet UX: restrict custom geohash input to valid base32 (lowercase, max 12), reduce spacing so placeholder sits closer to '#', add divider under country row, style 'remove permission' and hide row separators as before. Toolbar: minor spacing/width already adjusted.

* Sheet behavior: show channel list even without permission; add green 'get location and my geohash' button; ensure only one separator between country and teleport by removing manual Divider and showing default row separator; refine teleport input filtering and spacing.

* Channel activity nudges: notify after 9 minutes inactivity only in background; triple-tap clear also clears persistent timelines.

- ChatViewModel: track last activity per channel; send local notification when new activity resumes while app in background (with small cooldown)
- CommandProcessor: /clear clears current public channel persistence via viewModel helper

* Fix compile: add activeChannelDisplayName() and clearCurrentPublicTimeline() helpers in ChatViewModel.

* Fix concurrent dictionary access in BLEService.broadcastPacket: snapshot shared collections under collectionsQueue to avoid CocoaDictionary iterator crashes.

* People: channel-aware list and counts

- Sidebar: NETWORK → PEOPLE; removed PEOPLE subheader/icon
- Toolbar: blue #mesh badge; green #<geohash> badge; count color by channel
- Geohash participants: track per-geohash unique senders with 5m decay; publish list
- Update on geohash send/receive; 30s prune timer; channel switch hooks

No changes to mesh peer UX; mesh list retained as-is.

* BLE: snapshot collections for thread-safe access; stopServices uses snapshot; safe characteristic snapshot in broadcast path

LocationChannelsSheet: rename button label to 'remove location access'

* Channel sheet: show current peer counts

- Mesh row shows connected mesh peers count
- Geohash rows show 5m-active participant counts via ViewModel
- Live-updates while sheet is open

* Channel sheet: pluralize counts as 'person/people' in titles

* Geohash sampling: subscribe to all available channels while sheet open

- ViewModel: add multi-channel sampling subscriptions and per-geohash participant updates
- Sheet: start sampling on appear, sync on list changes, stop on disappear

* Channel sheet: render counts '(N person/people)' in smaller font next to label

* Channel sheet: use square brackets for counts and adjust parsing; fix mesh title to '#mesh'

* Geohash DMs: implement send/receive via NIP-17 with per-geohash identity

- NostrEmbeddedBitChat: add no-recipient encoder for geohash DMs
- NostrTransport: add sendPrivateMessageGeohash(using provided identity)
- ChatViewModel:
  • startGeohashDM + mapping helpers
  • subscribe to per-geohash gift wraps; store DMs in conversations keyed by 'nostr_<prefix>'
  • route sendPrivateMessage() for 'nostr_' to Nostr geohash send; local echo and status
  • map pubkeys from geohash public events for DM initiation
  • resubscribe DM feed on reconnect and channel switch; unsubscribe on leave
- ContentView: long-press 'private message' starts geohash DM when sender is nostr

* Geohash DMs UX: tap participants to DM; support /msg nickname in geohash; DM notifications

- People (geohash) list: bold 'you', sort to top, tap to open DM
- ChatViewModel.getPeerIDForNickname resolves geohash names to nostr_ conv keys
- Geohash DM receive: send local notification when not viewing

* Geohash DMs polish: header title, star hidden, self-DM blocked, message icon, delivered/read ACKs

- Header shows '#<geohash>/@name#abcd' for geohash DMs; hide favorite star
- Geohash people row: add small message icon; prevent self tap
- Prevent sending geohash DM to self
- Send delivery/read ACKs on receive; handle delivered/read to update status

* Geohash DMs: hide encryption status icon in DM header

* BLE: fix data race in broadcast path

- Snapshot peripherals via Array(values) and filter outside sync
- Snapshot subscribedCentrals and centralToPeerID under collectionsQueue
- Mutate subscribedCentrals under collectionsQueue barriers in didSubscribe/unsubscribe

* LocationChannelsSheet: open fully by default (large detent only)

* BLE: remove unused centralMapSnapshot variable

* Geohash DMs: only notify on incoming PM when app is backgrounded; avoid foreground noise

* GeoDM: reliable delivered/read receipts, background-only notifications, and UI tweak

- Implement delivered + read receipts for geohash DMs; send READ on open\n- Handle relay OK acks for gift-wrap sends; resubscribe processes PM/DELIVERED/READ\n- Prevent mesh Noise handshakes for virtual geohash peers (nostr_*)\n- Notify GeoDMs only in background; suppress alerts for already-read msgs\n- Logging: promote key GeoDM receive logs; demote/remove verbose noise\n- UI: remove trailing envelope button from geohash People list

* Fix: remove duplicate variable declarations in AnnouncementPacket.decode()

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-20 01:10:23 +02:00
committed by GitHub
co-authored by jack
parent 1c33a92765
commit b09710a7aa
18 changed files with 2143 additions and 258 deletions
+231 -185
View File
@@ -58,6 +58,9 @@ struct ContentView: View {
// MARK: - Properties
@EnvironmentObject var viewModel: ChatViewModel
#if os(iOS)
@ObservedObject private var locationManager = LocationChannelManager.shared
#endif
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -77,6 +80,7 @@ struct ContentView: View {
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
// MARK: - Computed Properties
@@ -202,7 +206,17 @@ struct ContentView: View {
) {
Button("private message") {
if let peerID = selectedMessageSenderID {
#if os(iOS)
if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
}
#else
viewModel.startPrivateChat(with: peerID)
#endif
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
@@ -601,7 +615,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
Text("NETWORK")
Text("PEOPLE")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
@@ -617,54 +631,102 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 6) {
// People section
VStack(alignment: .leading, spacing: 4) {
// Show appropriate header based on context
if !viewModel.allPeers.isEmpty {
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: 10))
.accessibilityHidden(true)
Text("PEOPLE")
.font(.system(size: 11, weight: .bold, design: .monospaced))
}
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
.padding(.top, 12)
}
if viewModel.allPeers.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
// Extract peer data for display
let peerNicknames = viewModel.meshService.getPeerNicknames()
// Show all peers (connected and favorites)
// Pre-compute peer data outside ForEach to reduce overhead
let peerData = viewModel.allPeers.map { peer in
// Get current myPeerID for each peer to avoid stale values
let currentMyPeerID = viewModel.meshService.myPeerID
return PeerDisplayData(
id: peer.id,
displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname,
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isMe: peer.id == currentMyPeerID,
hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id),
encryptionStatus: viewModel.getEncryptionStatus(for: peer.id),
connectionState: peer.connectionState,
isMutualFavorite: peer.favoriteStatus?.isMutual ?? false
)
}.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in
// Sort: favorites first, then alphabetically by nickname
if peer1.isFavorite != peer2.isFavorite {
return peer1.isFavorite
#if os(iOS)
switch locationManager.selectedChannel {
case .location:
if viewModel.geohashPeople.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
// Show 'you' at top and bold
let myHex: String? = {
if case .location(let ch) = locationManager.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let ordered = viewModel.geohashPeople.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
}
ForEach(ordered) { person in
HStack(spacing: 4) {
// Unread indicator matches mesh behavior
let convKey = "nostr_" + String(person.id.prefix(16))
if viewModel.unreadPrivateMessages.contains(convKey) {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread message from \(person.displayName)")
} else {
Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(textColor)
}
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
.font(.system(size: 14, design: .monospaced))
.fontWeight(person.id == myHex ? .bold : .regular)
.foregroundColor(textColor)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture {
// Open DM with this participant (not for self)
if person.id != myHex {
viewModel.startGeohashDM(withPubkeyHex: person.id)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
}
}
}
return peer1.displayName < peer2.displayName
}
ForEach(peerData) { peer in
default:
// Mesh peers list (original)
if viewModel.allPeers.isEmpty {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
// Extract peer data for display
let peerNicknames = viewModel.meshService.getPeerNicknames()
// Show all peers (connected and favorites)
// Pre-compute peer data outside ForEach to reduce overhead
let peerData = viewModel.allPeers.map { peer in
// Get current myPeerID for each peer to avoid stale values
let currentMyPeerID = viewModel.meshService.myPeerID
return PeerDisplayData(
id: peer.id,
displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname,
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isMe: peer.id == currentMyPeerID,
hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id),
encryptionStatus: viewModel.getEncryptionStatus(for: peer.id),
connectionState: peer.connectionState,
isMutualFavorite: peer.favoriteStatus?.isMutual ?? false
)
}.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in
// Sort: favorites first, then alphabetically by nickname
if peer1.isFavorite != peer2.isFavorite {
return peer1.isFavorite
}
return peer1.displayName < peer2.displayName
}
ForEach(peerData) { peer in
HStack(spacing: 4) {
// Signal strength indicator or unread message icon
if peer.isMe {
@@ -778,6 +840,9 @@ struct ContentView: View {
}
}
}
// Close switch
}
#endif
}
}
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
@@ -867,6 +932,26 @@ struct ContentView: View {
return (name, "")
}
#if os(iOS)
// Compute channel-aware people count and color for toolbar
private func channelPeopleCountAndColor() -> (Int, Color) {
switch locationManager.selectedChannel {
case .location:
let n = viewModel.geohashPeople.count
return (n, n > 0 ? Color.green : Color.secondary)
case .mesh:
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let color: Color = counts.mesh > 0 ? Color.blue : Color.secondary
return (counts.others, color)
}
}
#endif
private var mainHeaderView: some View {
HStack(spacing: 0) {
@@ -918,26 +1003,62 @@ struct ContentView: View {
.foregroundColor(Color.orange)
.accessibilityLabel("Unread private messages")
}
// Single pass to count both metrics
// People count depends on active channel
#if os(iOS)
let cc = channelPeopleCountAndColor()
let otherPeersCount = cc.0
let countColor = cc.1
#else
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected {
counts.mesh += 1
counts.others += 1
} else if peer.isMutualFavorite {
counts.others += 1
}
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let otherPeersCount = peerCounts.others
let meshPeerCount = peerCounts.mesh
// Purple only if we have peers but none are reachable via mesh (only via Nostr)
let isNostrOnly = otherPeersCount > 0 && meshPeerCount == 0
let countColor: Color = (peerCounts.mesh > 0) ? Color.blue : Color.secondary
#endif
// Location channels button '#'
#if os(iOS)
Button(action: { showLocationChannelsSheet = true }) {
#if os(iOS)
let badgeText: String = {
switch locationManager.selectedChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
}
}()
let badgeColor: Color = {
switch locationManager.selectedChannel {
case .mesh:
return Color.blue
case .location:
return Color.green
}
}()
Text(badgeText)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor)
.lineLimit(1)
.truncationMode(.head)
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing)
.fixedSize(horizontal: false, vertical: false)
.accessibilityLabel("location channels")
#else
Text("#")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.accessibilityLabel("location channels")
#endif
}
.buttonStyle(.plain)
.padding(.trailing, 6)
#endif
HStack(spacing: 4) {
// People icon with count
Image(systemName: "person.2.fill")
@@ -947,7 +1068,7 @@ struct ContentView: View {
.font(.system(size: 12, design: .monospaced))
.accessibilityHidden(true)
}
.foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? Color.blue : Color.secondary))
.foregroundColor(countColor)
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -958,6 +1079,11 @@ struct ContentView: View {
}
.frame(height: 44)
.padding(.horizontal, 12)
#if os(iOS)
.sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
}
#endif
.background(backgroundColor.opacity(0.95))
}
@@ -991,12 +1117,21 @@ struct ContentView: View {
// Resolve peer object for header context (may be offline favorite)
let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick = peer?.displayName ??
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
// getFavoriteStatusByNostrKey not implemented
// FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ??
"Unknown"
let privatePeerNick: String = {
if privatePeerID.hasPrefix("nostr_") {
#if os(iOS)
// Build geohash DM header: "#<ghash>/@name#abcd"
if case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)"
}
#endif
}
return peer?.displayName ??
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
"Unknown"
}()
let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else {
// Check if we can reach this peer via Nostr even if not in allPeers
@@ -1056,17 +1191,17 @@ struct ContentView: View {
Text("\(privatePeerNick)")
.font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
// Dynamic encryption status icon
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor :
Color.red)
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") {
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor :
Color.red)
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
}
}
}
.accessibilityLabel("Private chat with \(privatePeerNick)")
@@ -1093,17 +1228,19 @@ struct ContentView: View {
Spacer()
// Favorite button
Button(action: {
viewModel.toggleFavorite(peerID: headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.system(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
// Favorite button (hidden for geohash DMs)
if !(privatePeerID.hasPrefix("nostr_")) {
Button(action: {
viewModel.toggleFavorite(peerID: headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.system(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
.accessibilityHint("Double tap to toggle favorite status")
}
.buttonStyle(.plain)
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
.accessibilityHint("Double tap to toggle favorite status")
}
}
.frame(height: 44)
@@ -1115,7 +1252,7 @@ struct ContentView: View {
// MARK: - Helper Views
// Helper view for rendering message content with clickable hashtags
// Helper view for rendering message content (plain, no hashtag/mention formatting)
struct MessageContentView: View {
let message: BitchatMessage
let viewModel: ChatViewModel
@@ -1123,106 +1260,15 @@ struct MessageContentView: View {
let isMentioned: Bool
var body: some View {
let content = message.content
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionPattern = "@([a-zA-Z0-9_]+)"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
// Combine all matches and sort by location
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
allMatches.sort { $0.range.location < $1.range.location }
// Build the text as a concatenated Text view for natural wrapping
let segments = buildTextSegments()
var result = Text("")
for segment in segments {
if segment.type == "hashtag" {
// Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
result = result + Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.blue)
.underline()
} else if segment.type == "mention" {
result = result + Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.orange)
} else {
result = result + Text(segment.text)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
}
return result
Text(message.content)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
}
// MARK: - Helper Methods
private func buildTextSegments() -> [(text: String, type: String)] {
var segments: [(text: String, type: String)] = []
let content = message.content
var lastEnd = content.startIndex
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionPattern = "@([a-zA-Z0-9_]+)"
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
// Combine all matches and sort by location
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
allMatches.sort { $0.range.location < $1.range.location }
for (matchRange, matchType) in allMatches {
if let range = Range(matchRange, in: content) {
// Add text before the match
if lastEnd < range.lowerBound {
let beforeText = String(content[lastEnd..<range.lowerBound])
if !beforeText.isEmpty {
segments.append((beforeText, "text"))
}
}
// Add the match
let matchText = String(content[range])
segments.append((matchText, matchType))
lastEnd = range.upperBound
}
}
// Add any remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
if !remainingText.isEmpty {
segments.append((remainingText, "text"))
}
}
return segments
}
// buildTextSegments removed: content is rendered plain.
}
// Delivery status indicator view