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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-21 11:15:18 +02:00
committed by GitHub
co-authored by jack
parent d3d9a22757
commit bc27e16899
7 changed files with 153 additions and 48 deletions
+5 -1
View File
@@ -103,12 +103,16 @@ struct NostrProtocol {
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil nickname: String? = nil,
teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty { if let nickname = nickname, !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported {
tags.append(["t", "teleport"])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
+13 -1
View File
@@ -27,6 +27,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
@Published private(set) var permissionState: PermissionState = .notDetermined @Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = [] @Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh @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] = [:] @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
private override init() { 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) let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh)) 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) { private func reverseGeocodeIfNeeded(location: CLLocation) {
+61 -9
View File
@@ -150,6 +150,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var hasAnyUnreadMessages: Bool { var hasAnyUnreadMessages: Bool {
!unreadPrivateMessages.isEmpty !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 // Missing properties that were removed during refactoring
private var peerIDToPublicKeyFingerprint: [String: String] = [:] private var peerIDToPublicKeyFingerprint: [String: String] = [:]
@@ -255,6 +276,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoParticipants: [String: [String: Date]] = [:] private var geoParticipants: [String: [String: Date]] = [:]
@Published private(set) var geohashPeople: [GeoPerson] = [] @Published private(set) var geohashPeople: [GeoPerson] = []
private var geoParticipantsTimer: Timer? = nil private var geoParticipantsTimer: Timer? = nil
// Participants who indicated they teleported (by tag in their events)
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open) // Sampling subscriptions for multiple geohashes (when channel sheet is open)
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
#endif #endif
@@ -1097,7 +1120,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
content: content, content: content,
geohash: ch.geohash, geohash: ch.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: self.nickname nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
) )
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
// Track ourselves as active participant // Track ourselves as active participant
@@ -1130,6 +1154,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
messages = meshTimeline messages = meshTimeline
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll()
case .location(let ch): case .location(let ch):
messages = geoTimelines[ch.geohash] ?? [] messages = geoTimelines[ch.geohash] ?? []
} }
@@ -1149,6 +1174,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard case .location(let ch) = channel else { return } guard case .location(let ch) = channel else { return }
currentGeohash = ch.geohash 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)" let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID geoSubscriptionID = subID
startGeoParticipantsTimer() startGeoParticipantsTimer()
@@ -1160,6 +1195,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Deduplicate // Deduplicate
if self.processedNostrEvents.contains(event.id) { return } if self.processedNostrEvents.contains(event.id) { return }
self.recordProcessedEvent(event.id) 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) // Skip our own events (we already locally echoed)
if let gh = self.currentGeohash, if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
@@ -1181,6 +1224,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let senderName = self.displayNameForNostrPubkey(event.pubkey) let senderName = self.displayNameForNostrPubkey(event.pubkey)
let content = event.content 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 timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = self.parseMentions(from: content) let mentions = self.parseMentions(from: content)
let msg = BitchatMessage( let msg = BitchatMessage(
@@ -1332,6 +1380,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} catch { } catch {
// ignore // ignore
} }
// Presence announcement removed; we will tag actual chat events instead
} }
// MARK: - Geohash Participants (iOS) // MARK: - Geohash Participants (iOS)
@@ -2160,7 +2209,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
content: screenshotMessage, content: screenshotMessage,
geohash: ch.geohash, geohash: ch.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: self.nickname nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
) )
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
// Track ourselves as active participant // Track ourselves as active participant
@@ -2674,10 +2724,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString("<@").mergingAttributes(senderStyle)) result.append(AttributedString("<@").mergingAttributes(senderStyle))
// Base name // Base name
result.append(AttributedString(baseName).mergingAttributes(senderStyle)) 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 { if !suffix.isEmpty {
var suffixStyle = senderStyle var suffixStyle = senderStyle
suffixStyle.foregroundColor = Color.secondary.opacity(0.6) suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
} }
// Suffix "> " // Suffix "> "
@@ -2763,16 +2813,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}() }()
var mentionStyle = AttributeContainer() var mentionStyle = AttributeContainer()
mentionStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) 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 '@' // Emit '@'
result.append(AttributedString("@").mergingAttributes(mentionStyle)) result.append(AttributedString("@").mergingAttributes(mentionStyle))
// Base name // Base name
result.append(AttributedString(mBase).mergingAttributes(mentionStyle)) result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
// Suffix in light grey // Suffix in light grey
if !mSuffix.isEmpty { if !mSuffix.isEmpty {
var grey = mentionStyle var light = mentionStyle
grey.foregroundColor = Color.secondary.opacity(0.6) light.foregroundColor = mentionColor.opacity(0.6)
result.append(AttributedString(mSuffix).mergingAttributes(grey)) result.append(AttributedString(mSuffix).mergingAttributes(light))
} }
} else { } else {
var matchStyle = AttributeContainer() var matchStyle = AttributeContainer()
@@ -3814,7 +3865,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
content: content, content: content,
geohash: ch.geohash, geohash: ch.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: self.nickname nickname: self.nickname,
teleported: LocationChannelManager.shared.teleported
) )
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
} catch { } catch {
+18 -28
View File
@@ -834,16 +834,9 @@ struct ContentView: View {
Spacer() Spacer()
// People counter with unread indicator // Channel badge + dynamic spacing + people counter
HStack(spacing: 4) { HStack(spacing: 10) {
if viewModel.hasAnyUnreadMessages { // Unread icon immediately to the left of the channel badge (independent from channel button)
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread private messages")
}
// People count depends on active channel
#if os(iOS) #if os(iOS)
let cc = channelPeopleCountAndColor() let cc = channelPeopleCountAndColor()
let otherPeersCount = cc.0 let otherPeersCount = cc.0
@@ -861,25 +854,30 @@ struct ContentView: View {
let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif #endif
// Location channels button '#' // Unread indicator
#if os(iOS) #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 }) { Button(action: { showLocationChannelsSheet = true }) {
#if os(iOS)
let badgeText: String = { let badgeText: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: case .mesh: return "#mesh"
return "#mesh" case .location(let ch): return "#\(ch.geohash)"
case .location(let ch):
return "#\(ch.geohash)"
} }
}() }()
let badgeColor: Color = { let badgeColor: Color = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: case .mesh:
// Darker, more neutral blue (less purple hue)
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location: 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) 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)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) .fixedSize(horizontal: true, vertical: false)
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing) .layoutPriority(2)
.fixedSize(horizontal: false, vertical: false)
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
#else
Text("#")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.accessibilityLabel("location channels")
#endif
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, 6)
#endif #endif
HStack(spacing: 4) { HStack(spacing: 4) {
+17 -4
View File
@@ -30,36 +30,49 @@ struct GeohashPeopleList: View {
} }
return a.lastSeen > b.lastSeen return a.lastSeen > b.lastSeen
} }
let firstID = ordered.first?.id
ForEach(ordered) { person in ForEach(ordered) { person in
HStack(spacing: 4) { HStack(spacing: 4) {
let convKey = "nostr_" + String(person.id.prefix(16)) let convKey = "nostr_" + String(person.id.prefix(16))
if viewModel.unreadPrivateMessages.contains(convKey) { if viewModel.unreadPrivateMessages.contains(convKey) {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange) Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
} else { } 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 (base, suffix) = splitSuffix(from: person.displayName)
let isMe = person.id == myHex let isMe = person.id == myHex
HStack(spacing: 0) { HStack(spacing: 0) {
let rowColor: Color = isMe ? .orange : textColor
Text(base) Text(base)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular) .fontWeight(isMe ? .bold : .regular)
.foregroundColor(textColor) .foregroundColor(rowColor)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : textColor.opacity(0.6)
Text(suffix) Text(suffix)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.secondary.opacity(0.6)) .foregroundColor(suffixColor)
} }
if isMe { if isMe {
Text(" (you)") Text(" (you)")
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(rowColor)
} }
} }
Spacer() Spacer()
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, person.id == firstID ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if person.id != myHex { if person.id != myHex {
+12 -1
View File
@@ -107,6 +107,8 @@ struct LocationChannelsSheet: View {
let subtitlePrefix = "#\(channel.geohash)\(coverage)" let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { 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)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
@@ -145,13 +147,22 @@ struct LocationChannelsSheet: View {
} }
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized) let isValid = validateGeohash(normalized)
Button("teleport") { Button(action: {
let gh = normalized let gh = normalized
guard isValid else { customError = "invalid geohash"; return } guard isValid else { customError = "invalid geohash"; return }
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
// Mark this selection as a manual teleport
manager.teleported = true
manager.select(ChannelID.location(ch)) manager.select(ChannelID.location(ch))
isPresented = false isPresented = false
}) {
HStack(spacing: 6) {
Text("teleport")
.font(.system(size: 14, design: .monospaced))
Image(systemName: "face.dashed")
.font(.system(size: 14))
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
+27 -4
View File
@@ -60,9 +60,19 @@ struct MeshPeerList: View {
} }
let displayName = isMe ? viewModel.nickname : peer.nickname let displayName = isMe ? viewModel.nickname : peer.nickname
Text(displayName) let (base, suffix) = splitSuffix(from: displayName)
.font(.system(size: 14, design: .monospaced)) let baseColor = ((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil) ? textColor : secondaryTextColor
.foregroundColor((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 { if let icon = item.enc.icon, !isMe {
Image(systemName: icon) Image(systemName: icon)
@@ -83,7 +93,7 @@ struct MeshPeerList: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 6 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } } .onTapGesture { if !isMe { onTapPeer(peer.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(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, "")
}