Files
bitchat/bitchat/Views/LocationChannelsSheet.swift
T
jackandClaude Fable 5 fb80403cd4 Gateway mode: opt-in mesh↔Nostr uplink for geohash channels
An opt-in "internet gateway" toggle lets one connected phone bridge the
local geohash channel for mesh-only peers: signed kind-20000 events ride
a new nostrCarrier (0x28) packet — directed to the gateway for uplink,
broadcast with TTL for downlink — with Schnorr verification at every
hop, CourierStore-style quotas, and explicit loop-prevention rules.

- BitFoundation: MessageType.nostrCarrier = 0x28
- NostrCarrierPacket: 2-byte-length TLV codec (direction, geohash,
  signed event JSON), 16 KiB cap, tolerant decoder
- GatewayService: closure-injected policy layer — verify gates (sig,
  kind, #g tag, age, size), uplink quotas (10/min/depositor rate limit,
  offline queue of 20 total / 5 per depositor, drop-oldest, flush on
  reconnect), downlink budget (30/min, bounded drop-oldest backlog),
  bounded loop-prevention ID sets
- BLEService: runtime capability bits (advertise .gateway only while
  the toggle is on, re-announce on change), signed directed uplink
  sends, carrier ingress with depositor signature verification
- Mesh-only senders uplink automatically from sendGeohash when no relay
  is connected and a reachable peer advertises .gateway; once-per-
  channel "sent via mesh gateway" notice
- UI: gateway toggle beside the Tor toggle, globe header indicator,
  VoiceOver labels, xcstrings entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:31:09 +02:00

646 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import CoreLocation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
static let description: LocalizedStringKey = "location_channels.description"
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
return rowTitle(label: label, count: count)
}
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
// High-precision uncertainty: if count is 0 for high-precision levels,
// show "?" because presence broadcasting is disabled for privacy.
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
level.displayName
)
}
return rowTitle(label: level.displayName, count: count)
}
static func bookmarkTitle(geohash: String, count: Int) -> String {
// Check precision for bookmarks too
let len = geohash.count
// Neighborhood=6, Block=7, Building=8+
let isHighPrecision = (len >= 6)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
"#\(geohash)"
)
}
return rowTitle(label: "#\(geohash)", count: count)
}
static func subtitlePrefix(geohash: String, coverage: String) -> String {
String(
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
locale: .current,
geohash, coverage
)
}
static func subtitle(prefix: String, name: String?) -> String {
guard let name, !name.isEmpty else { return prefix }
return String(
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
locale: .current,
prefix, name
)
}
private static func rowTitle(label: String, count: Int) -> String {
String(
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
locale: .current,
label, count
)
}
}
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text(Strings.title)
.bitchatFont(size: 18)
Spacer()
closeButton
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Group {
switch locationChannelsModel.permissionState {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.bitchatFont(size: 12)
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(standardGreen.opacity(0.12))
.cornerRadius(6)
}
.buttonStyle(.plain)
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
case .authorized:
EmptyView()
}
}
channelList
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.themedSheetBackground()
.onAppear {
// Refresh channels when opening
if locationChannelsModel.permissionState == .authorized {
locationChannelsModel.refreshChannels()
}
// Begin periodic refresh while sheet is open
locationChannelsModel.beginLiveRefresh()
// Geohash sampling is now managed by ChatViewModel globally
}
.onDisappear {
locationChannelsModel.endLiveRefresh()
}
.onChange(of: locationChannelsModel.permissionState) { newValue in
if newValue == .authorized {
locationChannelsModel.refreshChannels()
}
}
.onChange(of: locationChannelsModel.availableChannels) { _ in }
}
private var closeButton: some View {
SheetCloseButton { isPresented = false }
}
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
locationChannelsModel.select(ChannelID.mesh)
isPresented = false
}
.padding(.vertical, 6)
let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building }
if !nearby.isEmpty {
ForEach(nearby) { channel in
sectionDivider
let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let participantCount = peerListModel.participantCount(for: channel.geohash)
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
let highlight = participantCount > 0
channelRow(
title: Strings.levelTitle(for: channel.level, count: participantCount),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
}
} else {
sectionDivider
HStack(spacing: 8) {
ProgressView()
Text(Strings.loadingNearby)
.bitchatFont(size: 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
}
sectionDivider
customTeleportSection
.padding(.vertical, 8)
let bookmarkedList = locationChannelsModel.bookmarks
if !bookmarkedList.isEmpty {
sectionDivider
bookmarkedSection(bookmarkedList)
.padding(.vertical, 8)
}
if locationChannelsModel.permissionState == .authorized {
sectionDivider
torToggleSection
.padding(.top, 12)
gatewayToggleSection
.padding(.top, 8)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
.cornerRadius(6)
}
.buttonStyle(.plain)
.padding(.vertical, 8)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.themedSurface()
}
.themedSurface()
}
private var sectionDivider: some View {
Rectangle()
.fill(dividerColor)
.frame(height: 1)
}
private var dividerColor: Color { palette.divider }
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text(verbatim: "#")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in
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
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized)
Button(action: {
let gh = normalized
guard isValid else { customError = Strings.invalidGeohash; return }
locationChannelsModel.teleport(to: gh)
isPresented = false
}) {
HStack(spacing: 6) {
Text(Strings.teleport)
.bitchatFont(size: 14)
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
}
}
.buttonStyle(.plain)
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(palette.secondary.opacity(0.12))
.cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid)
}
if let err = customError {
Text(err)
.bitchatFont(size: 12)
.foregroundColor(.red)
}
}
}
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
let name = locationChannelsModel.bookmarkNames[gh]
let participantCount = peerListModel.participantCount(for: gh)
channelRow(
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: locationChannelsModel.isSelected(channel),
trailingAccessory: {
Button(action: { locationChannelsModel.toggleBookmark(gh) }) {
Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
locationChannelsModel.markTeleported(for: gh, true)
} else {
locationChannelsModel.markTeleported(for: gh, false)
}
locationChannelsModel.select(ChannelID.location(channel))
isPresented = false
}
.padding(.vertical, 6)
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
if index < entries.count - 1 {
sectionDivider
}
}
}
}
}
private func isSelected(_ channel: GeohashChannel) -> Bool {
locationChannelsModel.isSelected(channel)
}
private var isMeshSelected: Bool {
if case .mesh = locationChannelsModel.selectedChannel { return true }
return false
}
@ViewBuilder
private func channelRow(
title: String,
subtitlePrefix: String,
subtitleName: String? = nil,
subtitleNameBold: Bool = false,
isSelected: Bool,
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading) {
// Render title with smaller font for trailing count in parentheses
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? palette.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
if isSelected {
Text(verbatim: "✔︎")
.bitchatFont(size: 16)
.foregroundColor(standardGreen)
}
trailingAccessory()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: action)
// The row is a plain HStack with a tap gesture, which VoiceOver reads
// as disconnected static text. Expose it as one activatable button;
// the visible bookmark accessory is mirrored as a named action.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
.accessibilityHint(Strings.switchChannelHint)
.accessibilityAction(.default, action)
.accessibilityActions {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
}
}
// 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]).trimmed
let suffix = String(s[idx...])
return (prefix, suffix)
}
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 .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8: return .building
default: return .block
}
}
}
// MARK: - TOR Toggle & Standardized Colors
extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.userTorEnabled },
set: { locationChannelsModel.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
private var gatewayToggleBinding: Binding<Bool> {
Binding(
get: { locationChannelsModel.gatewayEnabled },
set: { locationChannelsModel.setGatewayEnabled($0) }
)
}
private var gatewayToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: gatewayToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.gatewayTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
Text(Strings.gatewaySubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
let onLabel: LocalizedStringKey
let offLabel: LocalizedStringKey
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// 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, macOS 13.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, macOS 13.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
return usesMetric ? "~1050 m" : "~30160 ft"
}
private func locationName(for level: GeohashChannelLevel) -> String? {
locationChannelsModel.locationName(for: level)
}
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
switch level {
case .region:
return ""
case .building, .block, .neighborhood, .city, .province:
return "~"
}
}
}