mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:25:20 +00:00
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:
@@ -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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user