From bc27e16899aab2e0ffbeb5912b246e87fbdfb0e8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:15:18 +0200 Subject: [PATCH] Fix/visuals (#469) * Geohash peers: show face.dashed for self when channel selected via teleport; face.smiling otherwise * Geo presence: broadcast 'teleport' tag on geochat join; track teleported participants and show face.dashed for them in peer list * Teleport tag: attach to actual geohash chat events (sendMessage, emotes, screenshots) instead of separate presence; remove presence emit * Fix: show face.dashed for any teleported peer (not just self) in geohash list * Geo list: show self immediately on channel switch and mark teleported state; clear teleported flags on leaving geochat * Teleport persistence: recompute teleported based on current location vs selected geohash; add face.dashed icon to Teleport button label * Styling: use lighter green/orange for #abcd suffix after nicknames in all chats (senders and @mentions) * Peer lists: render #abcd suffix as lighter green/orange (self orange) in geohash and mesh lists * Toolbar: move unread icon next to #channel badge and allow dynamic width; Peer lists: increase top spacing before first item * Toolbar: prevent geohash channel badge from truncating; give it layout priority and fixed width * Toolbar: make unread envelope independent from channel button (sits left of badge); fix accidental taps opening channel selector * Toolbar: make unread envelope open most recent unread/private chat directly * Geohash peer list: render self row fully orange (icon, base, suffix, '(you)') --------- Co-authored-by: jack --- bitchat/Nostr/NostrProtocol.swift | 6 +- bitchat/Services/LocationChannelManager.swift | 14 +++- bitchat/ViewModels/ChatViewModel.swift | 70 ++++++++++++++++--- bitchat/Views/ContentView.swift | 46 +++++------- bitchat/Views/GeohashPeopleList.swift | 21 ++++-- bitchat/Views/LocationChannelsSheet.swift | 13 +++- bitchat/Views/MeshPeerList.swift | 31 ++++++-- 7 files changed, 153 insertions(+), 48 deletions(-) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 449c236f..81ba5c62 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -103,12 +103,16 @@ struct NostrProtocol { content: String, geohash: String, senderIdentity: NostrIdentity, - nickname: String? = nil + nickname: String? = nil, + teleported: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname, !nickname.isEmpty { tags.append(["n", nickname]) } + if teleported { + tags.append(["t", "teleport"]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 85e1eb63..5c76ff6a 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -27,6 +27,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa @Published private(set) var permissionState: PermissionState = .notDetermined @Published private(set) var availableChannels: [GeohashChannel] = [] @Published private(set) var selectedChannel: ChannelID = .mesh + // True when the current location channel was selected via manual teleport + @Published var teleported: Bool = false @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] private override init() { @@ -160,7 +162,17 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) result.append(GeohashChannel(level: level, geohash: gh)) } - Task { @MainActor in self.availableChannels = result } + Task { @MainActor in + self.availableChannels = result + // Recompute teleported status based on current location vs selected channel + switch self.selectedChannel { + case .mesh: + self.teleported = false + case .location(let ch): + let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) + self.teleported = (currentGH != ch.geohash) + } + } } private func reverseGeocodeIfNeeded(location: CLLocation) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a93e77be..33734926 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -150,6 +150,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var hasAnyUnreadMessages: Bool { !unreadPrivateMessages.isEmpty } + + /// Open the most relevant private chat when tapping the toolbar unread icon. + /// Prefers the most recently active unread conversation, otherwise the most recent PM. + @MainActor + func openMostRelevantPrivateChat() { + // Pick most recent unread by last message timestamp + let unreadSorted = unreadPrivateMessages + .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + .sorted { $0.1 > $1.1 } + if let target = unreadSorted.first?.0 { + startPrivateChat(with: target) + return + } + // Otherwise pick most recent private chat overall + let recent = privateChats + .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } + .sorted { $0.ts > $1.ts } + if let target = recent.first?.id { + startPrivateChat(with: target) + } + } // Missing properties that were removed during refactoring private var peerIDToPublicKeyFingerprint: [String: String] = [:] @@ -255,6 +276,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private var geoParticipants: [String: [String: Date]] = [:] @Published private(set) var geohashPeople: [GeoPerson] = [] private var geoParticipantsTimer: Timer? = nil + // Participants who indicated they teleported (by tag in their events) + @Published private(set) var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) private var geoSamplingSubs: [String: String] = [:] // subID -> geohash #endif @@ -1097,7 +1120,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { content: content, geohash: ch.geohash, senderIdentity: identity, - nickname: self.nickname + nickname: self.nickname, + teleported: LocationChannelManager.shared.teleported ) NostrRelayManager.shared.sendEvent(event) // Track ourselves as active participant @@ -1130,6 +1154,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { messages = meshTimeline stopGeoParticipantsTimer() geohashPeople = [] + teleportedGeo.removeAll() case .location(let ch): messages = geoTimelines[ch.geohash] ?? [] } @@ -1149,6 +1174,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard case .location(let ch) = channel else { return } currentGeohash = ch.geohash + // Ensure self appears immediately in the people list; mark teleported state if applicable + if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) + #if os(iOS) + if LocationChannelManager.shared.teleported { + teleportedGeo.insert(id.publicKeyHex.lowercased()) + objectWillChange.send() + } + #endif + } let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID startGeoParticipantsTimer() @@ -1160,6 +1195,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Deduplicate if self.processedNostrEvents.contains(event.id) { return } self.recordProcessedEvent(event.id) + // Track teleport tag for participants + if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport" { + let key = event.pubkey.lowercased() + if !self.teleportedGeo.contains(key) { + self.teleportedGeo.insert(key) + DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } + } + } // Skip our own events (we already locally echoed) if let gh = self.currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), @@ -1181,6 +1224,11 @@ 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", + content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return + } let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let mentions = self.parseMentions(from: content) let msg = BitchatMessage( @@ -1332,6 +1380,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } catch { // ignore } + // Presence announcement removed; we will tag actual chat events instead } // MARK: - Geohash Participants (iOS) @@ -2160,7 +2209,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { content: screenshotMessage, geohash: ch.geohash, senderIdentity: identity, - nickname: self.nickname + nickname: self.nickname, + teleported: LocationChannelManager.shared.teleported ) NostrRelayManager.shared.sendEvent(event) // Track ourselves as active participant @@ -2674,10 +2724,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { result.append(AttributedString("<@").mergingAttributes(senderStyle)) // Base name result.append(AttributedString(baseName).mergingAttributes(senderStyle)) - // Optional suffix (light gray) + // Optional suffix in lighter variant of the base color (green or orange for self) if !suffix.isEmpty { var suffixStyle = senderStyle - suffixStyle.foregroundColor = Color.secondary.opacity(0.6) + suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } // Suffix "> " @@ -2763,16 +2813,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }() var mentionStyle = AttributeContainer() mentionStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) - mentionStyle.foregroundColor = isMentionToMe ? .orange : primaryColor + let mentionColor: Color = isMentionToMe ? .orange : primaryColor + mentionStyle.foregroundColor = mentionColor // Emit '@' result.append(AttributedString("@").mergingAttributes(mentionStyle)) // Base name result.append(AttributedString(mBase).mergingAttributes(mentionStyle)) // Suffix in light grey if !mSuffix.isEmpty { - var grey = mentionStyle - grey.foregroundColor = Color.secondary.opacity(0.6) - result.append(AttributedString(mSuffix).mergingAttributes(grey)) + var light = mentionStyle + light.foregroundColor = mentionColor.opacity(0.6) + result.append(AttributedString(mSuffix).mergingAttributes(light)) } } else { var matchStyle = AttributeContainer() @@ -3814,7 +3865,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { content: content, geohash: ch.geohash, senderIdentity: identity, - nickname: self.nickname + nickname: self.nickname, + teleported: LocationChannelManager.shared.teleported ) NostrRelayManager.shared.sendEvent(event) } catch { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index d4950403..e13c28c2 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -834,16 +834,9 @@ struct ContentView: View { Spacer() - // People counter with unread indicator - HStack(spacing: 4) { - if viewModel.hasAnyUnreadMessages { - Image(systemName: "envelope.fill") - .font(.system(size: 12)) - .foregroundColor(Color.orange) - .accessibilityLabel("Unread private messages") - } - - // People count depends on active channel + // Channel badge + dynamic spacing + people counter + HStack(spacing: 10) { + // Unread icon immediately to the left of the channel badge (independent from channel button) #if os(iOS) let cc = channelPeopleCountAndColor() let otherPeersCount = cc.0 @@ -861,25 +854,30 @@ struct ContentView: View { let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary #endif - // Location channels button '#' + // Unread indicator #if os(iOS) + if viewModel.hasAnyUnreadMessages { + Button(action: { viewModel.openMostRelevantPrivateChat() }) { + Image(systemName: "envelope.fill") + .font(.system(size: 12)) + .foregroundColor(Color.orange) + } + .buttonStyle(.plain) + .accessibilityLabel("Open unread private chat") + } + // Location channels button '#' Button(action: { showLocationChannelsSheet = true }) { - #if os(iOS) let badgeText: String = { switch locationManager.selectedChannel { - case .mesh: - return "#mesh" - case .location(let ch): - return "#\(ch.geohash)" + case .mesh: return "#mesh" + case .location(let ch): return "#\(ch.geohash)" } }() let badgeColor: Color = { switch locationManager.selectedChannel { case .mesh: - // Darker, more neutral blue (less purple hue) return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) case .location: - // Standard green to avoid overly bright appearance in light mode return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) } }() @@ -887,19 +885,11 @@ struct ContentView: View { .font(.system(size: 14, design: .monospaced)) .foregroundColor(badgeColor) .lineLimit(1) - .truncationMode(.head) - .frame(minWidth: 60, maxWidth: 160, alignment: .trailing) - .fixedSize(horizontal: false, vertical: false) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) .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) { diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 3106fbb8..a875dfff 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -30,36 +30,49 @@ struct GeohashPeopleList: View { } return a.lastSeen > b.lastSeen } + let firstID = ordered.first?.id ForEach(ordered) { person in 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 { - Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor) + // For the local user, use a different face icon when teleported + let isMe = (person.id == myHex) + #if os(iOS) + let teleported = isMe ? LocationChannelManager.shared.teleported : viewModel.teleportedGeo.contains(person.id.lowercased()) + #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 HStack(spacing: 0) { + let rowColor: Color = isMe ? .orange : textColor Text(base) .font(.system(size: 14, design: .monospaced)) .fontWeight(isMe ? .bold : .regular) - .foregroundColor(textColor) + .foregroundColor(rowColor) if !suffix.isEmpty { + let suffixColor = isMe ? Color.orange.opacity(0.6) : textColor.opacity(0.6) Text(suffix) .font(.system(size: 14, design: .monospaced)) - .foregroundColor(Color.secondary.opacity(0.6)) + .foregroundColor(suffixColor) } if isMe { Text(" (you)") .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) + .foregroundColor(rowColor) } } Spacer() } .padding(.horizontal) .padding(.vertical, 4) + .padding(.top, person.id == firstID ? 10 : 0) .contentShape(Rectangle()) .onTapGesture { if person.id != myHex { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index f129ba0e..84d4fd58 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -107,6 +107,8 @@ struct LocationChannelsSheet: View { let subtitlePrefix = "#\(channel.geohash) • \(coverage)" let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { + // Selecting a suggested nearby channel is not a teleport + manager.teleported = false manager.select(ChannelID.location(channel)) isPresented = false } @@ -145,13 +147,22 @@ struct LocationChannelsSheet: View { } let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") let isValid = validateGeohash(normalized) - Button("teleport") { + Button(action: { let gh = normalized guard isValid else { customError = "invalid geohash"; return } let level = levelForLength(gh.count) let ch = GeohashChannel(level: level, geohash: gh) + // Mark this selection as a manual teleport + manager.teleported = true manager.select(ChannelID.location(ch)) isPresented = false + }) { + HStack(spacing: 6) { + Text("teleport") + .font(.system(size: 14, design: .monospaced)) + Image(systemName: "face.dashed") + .font(.system(size: 14)) + } } .buttonStyle(.plain) .font(.system(size: 14, design: .monospaced)) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d53e961..14ca6a1b 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -60,9 +60,19 @@ struct MeshPeerList: View { } let displayName = isMe ? viewModel.nickname : peer.nickname - Text(displayName) - .font(.system(size: 14, design: .monospaced)) - .foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) + let (base, suffix) = splitSuffix(from: displayName) + let baseColor = ((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil) ? textColor : secondaryTextColor + HStack(spacing: 0) { + Text(base) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(baseColor) + if !suffix.isEmpty { + let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) + Text(suffix) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(suffixColor) + } + } if let icon = item.enc.icon, !isMe { Image(systemName: icon) @@ -83,7 +93,7 @@ struct MeshPeerList: View { } .padding(.horizontal) .padding(.vertical, 4) - .padding(.top, idx == 0 ? 6 : 0) + .padding(.top, idx == 0 ? 10 : 0) .contentShape(Rectangle()) .onTapGesture { if !isMe { onTapPeer(peer.id) } } .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } } @@ -92,3 +102,16 @@ struct MeshPeerList: View { } } } + +// Helper to split a trailing #abcd suffix +private func splitSuffix(from name: String) -> (String, String) { + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") +}