mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:45:21 +00:00
* 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>
394 lines
17 KiB
Swift
394 lines
17 KiB
Swift
import SwiftUI
|
||
|
||
#if os(iOS)
|
||
import UIKit
|
||
struct LocationChannelsSheet: View {
|
||
@Binding var isPresented: Bool
|
||
@ObservedObject private var manager = LocationChannelManager.shared
|
||
@EnvironmentObject var viewModel: ChatViewModel
|
||
@Environment(\.colorScheme) var colorScheme
|
||
@State private var customGeohash: String = ""
|
||
@State private var customError: String? = nil
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("#location channels")
|
||
.font(.system(size: 18, design: .monospaced))
|
||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
|
||
Group {
|
||
switch manager.permissionState {
|
||
case LocationChannelManager.PermissionState.notDetermined:
|
||
Button(action: { manager.enableLocationChannels() }) {
|
||
Text("get location and my geohashes")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(standardGreen)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 6)
|
||
.background(standardGreen.opacity(0.12))
|
||
.cornerRadius(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("location permission denied. enable in settings to use location channels.")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Button("open settings") {
|
||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
case LocationChannelManager.PermissionState.authorized:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
channelList
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button("close") { isPresented = false }
|
||
.font(.system(size: 14, design: .monospaced))
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.large])
|
||
.onAppear {
|
||
// Refresh channels when opening
|
||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||
manager.refreshChannels()
|
||
}
|
||
// Begin periodic refresh while sheet is open
|
||
manager.beginLiveRefresh()
|
||
// Begin multi-channel sampling for counts
|
||
let ghs = manager.availableChannels.map { $0.geohash }
|
||
viewModel.beginGeohashSampling(for: ghs)
|
||
}
|
||
.onDisappear {
|
||
manager.endLiveRefresh()
|
||
viewModel.endGeohashSampling()
|
||
}
|
||
.onChange(of: manager.permissionState) { newValue in
|
||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||
manager.refreshChannels()
|
||
}
|
||
}
|
||
.onChange(of: manager.availableChannels) { newValue in
|
||
// Keep sampling list in sync with available channels as they refresh live
|
||
let ghs = newValue.map { $0.geohash }
|
||
viewModel.beginGeohashSampling(for: ghs)
|
||
}
|
||
}
|
||
|
||
private var channelList: some View {
|
||
List {
|
||
// Mesh option first
|
||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||
manager.select(ChannelID.mesh)
|
||
isPresented = false
|
||
}
|
||
|
||
// Nearby options
|
||
if !manager.availableChannels.isEmpty {
|
||
ForEach(manager.availableChannels) { channel in
|
||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||
let nameBase = locationName(for: channel.level)
|
||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||
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
|
||
}
|
||
}
|
||
} else {
|
||
HStack {
|
||
ProgressView()
|
||
Text("finding nearby channels…")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
}
|
||
}
|
||
|
||
// Custom geohash teleport
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 2) {
|
||
Text("#")
|
||
.font(.system(size: 14, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
TextField("geohash", text: $customGeohash)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.font(.system(size: 14, design: .monospaced))
|
||
.keyboardType(.asciiCapable)
|
||
.onChange(of: customGeohash) { newValue in
|
||
// Allow only geohash base32 characters, strip '#', limit length
|
||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||
let filtered = newValue
|
||
.lowercased()
|
||
.replacingOccurrences(of: "#", with: "")
|
||
.filter { allowed.contains($0) }
|
||
if filtered.count > 12 {
|
||
customGeohash = String(filtered.prefix(12))
|
||
} else if filtered != newValue {
|
||
customGeohash = filtered
|
||
}
|
||
}
|
||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||
let isValid = validateGeohash(normalized)
|
||
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))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(Color.secondary.opacity(0.12))
|
||
.cornerRadius(6)
|
||
.opacity(isValid ? 1.0 : 0.4)
|
||
.disabled(!isValid)
|
||
}
|
||
if let err = customError {
|
||
Text(err)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
|
||
// Footer action inside the list
|
||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||
Button(action: {
|
||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}) {
|
||
Text("remove location access")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 6)
|
||
.background(Color.red.opacity(0.08))
|
||
.cornerRadius(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
}
|
||
|
||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||
if case .location(let ch) = manager.selectedChannel {
|
||
return ch == channel
|
||
}
|
||
return false
|
||
}
|
||
|
||
private var isMeshSelected: Bool {
|
||
if case .mesh = manager.selectedChannel { return true }
|
||
return false
|
||
}
|
||
|
||
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
// Render title with smaller font for trailing count in parentheses
|
||
let parts = splitTitleAndCount(title)
|
||
HStack(spacing: 4) {
|
||
Text(parts.base)
|
||
.font(.system(size: 14, design: .monospaced))
|
||
.fontWeight(titleBold ? .bold : .regular)
|
||
.foregroundColor(titleColor ?? Color.primary)
|
||
if let count = parts.countSuffix, !count.isEmpty {
|
||
Text(count)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
HStack(spacing: 0) {
|
||
Text(subtitlePrefix)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
if let name = subtitleName {
|
||
Text(" • ")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Text(name)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
Spacer()
|
||
if isSelected {
|
||
Text("✔︎")
|
||
.font(.system(size: 16, design: .monospaced))
|
||
.foregroundColor(standardGreen)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||
let suffix = String(s[idx...])
|
||
return (prefix, suffix)
|
||
}
|
||
|
||
// MARK: - Helpers for counts
|
||
private func meshTitleWithCount() -> String {
|
||
// Count currently connected mesh peers (excluding self)
|
||
let meshCount = meshCount()
|
||
let noun = meshCount == 1 ? "person" : "people"
|
||
return "mesh [\(meshCount) \(noun)]"
|
||
}
|
||
|
||
private func meshCount() -> Int {
|
||
let myID = viewModel.meshService.myPeerID
|
||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||
return acc
|
||
}
|
||
}
|
||
|
||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||
let noun = count == 1 ? "person" : "people"
|
||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||
}
|
||
|
||
private func validateGeohash(_ s: String) -> Bool {
|
||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||
return s.allSatisfy { allowed.contains($0) }
|
||
}
|
||
|
||
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||
switch len {
|
||
case 0...2: return .country
|
||
case 3...4: return .region
|
||
case 5: return .city
|
||
case 6: return .neighborhood
|
||
case 7: return .block
|
||
default: return .block
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Standardized Colors
|
||
extension LocationChannelsSheet {
|
||
private var standardGreen: Color {
|
||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||
}
|
||
private var standardBlue: Color {
|
||
Color(red: 0.0, green: 0.478, blue: 1.0)
|
||
}
|
||
}
|
||
|
||
// MARK: - Coverage helpers
|
||
extension LocationChannelsSheet {
|
||
private func coverageString(forPrecision len: Int) -> String {
|
||
// Approximate max cell dimension at equator for a given geohash length.
|
||
// Values sourced from common geohash dimension tables.
|
||
let maxMeters: Double = {
|
||
switch len {
|
||
case 2: return 1_250_000
|
||
case 3: return 156_000
|
||
case 4: return 39_100
|
||
case 5: return 4_890
|
||
case 6: return 1_220
|
||
case 7: return 153
|
||
case 8: return 38.2
|
||
case 9: return 4.77
|
||
case 10: return 1.19
|
||
default:
|
||
if len <= 1 { return 5_000_000 }
|
||
// For >10, scale down conservatively by ~1/4 each char
|
||
let over = len - 10
|
||
return 1.19 * pow(0.25, Double(over))
|
||
}
|
||
}()
|
||
|
||
let usesMetric: Bool = {
|
||
if #available(iOS 16.0, *) {
|
||
return Locale.current.measurementSystem == .metric
|
||
} else {
|
||
return Locale.current.usesMetricSystem
|
||
}
|
||
}()
|
||
if usesMetric {
|
||
let km = maxMeters / 1000.0
|
||
return "~\(formatDistance(km)) km"
|
||
} else {
|
||
let miles = maxMeters / 1609.344
|
||
return "~\(formatDistance(miles)) mi"
|
||
}
|
||
}
|
||
|
||
private func formatDistance(_ value: Double) -> String {
|
||
if value >= 100 { return String(format: "%.0f", value.rounded()) }
|
||
if value >= 10 { return String(format: "%.1f", value) }
|
||
return String(format: "%.1f", value)
|
||
}
|
||
|
||
private func bluetoothRangeString() -> String {
|
||
let usesMetric: Bool = {
|
||
if #available(iOS 16.0, *) {
|
||
return Locale.current.measurementSystem == .metric
|
||
} else {
|
||
return Locale.current.usesMetricSystem
|
||
}
|
||
}()
|
||
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
|
||
return usesMetric ? "~10–50 m" : "~30–160 ft"
|
||
}
|
||
|
||
private func locationName(for level: GeohashChannelLevel) -> String? {
|
||
manager.locationNames[level]
|
||
}
|
||
|
||
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
|
||
switch level {
|
||
case .country:
|
||
return ""
|
||
default:
|
||
return "~"
|
||
}
|
||
}
|
||
}
|
||
|
||
#endif
|