mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:45:21 +00:00
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>
384 lines
17 KiB
Swift
384 lines
17 KiB
Swift
import SwiftUI
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
struct ContentHeaderView: View {
|
|
@EnvironmentObject private var appChromeModel: AppChromeModel
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
@EnvironmentObject private var peerListModel: PeerListModel
|
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
@Environment(\.appTheme) private var theme
|
|
@ThemedPalette private var palette
|
|
|
|
@Binding var showSidebar: Bool
|
|
@Binding var showVerifySheet: Bool
|
|
@Binding var showLocationNotes: Bool
|
|
@Binding var notesGeohash: String?
|
|
var isNicknameFieldFocused: FocusState<Bool>.Binding
|
|
|
|
let headerHeight: CGFloat
|
|
let headerPeerIconSize: CGFloat
|
|
let headerPeerCountFontSize: CGFloat
|
|
|
|
/// Courier envelopes this device is carrying for offline third parties.
|
|
@State private var carriedMailCount = 0
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
Text(verbatim: "bitchat/")
|
|
.bitchatFont(size: 18, weight: .medium)
|
|
.foregroundColor(palette.primary)
|
|
.onTapGesture(count: 3) {
|
|
appChromeModel.panicClearAllData()
|
|
}
|
|
.onTapGesture(count: 1) {
|
|
appChromeModel.presentAppInfo()
|
|
}
|
|
// This is the only entry point to App Info, but it reads as
|
|
// static text; surface the tap. (The triple-tap panic wipe
|
|
// stays undiscoverable on purpose — it's destructive.)
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityHint(
|
|
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
|
)
|
|
.accessibilityAction {
|
|
appChromeModel.presentAppInfo()
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
Text(verbatim: "@")
|
|
.bitchatFont(size: 14)
|
|
.foregroundColor(palette.secondary)
|
|
|
|
TextField(
|
|
"content.input.nickname_placeholder",
|
|
text: Binding(
|
|
get: { appChromeModel.nickname },
|
|
set: { appChromeModel.setNickname($0) }
|
|
)
|
|
)
|
|
.textFieldStyle(.plain)
|
|
.bitchatFont(size: 14)
|
|
.frame(maxWidth: 80)
|
|
.foregroundColor(palette.primary)
|
|
.focused(isNicknameFieldFocused)
|
|
.autocorrectionDisabled(true)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.modifier(FocusEffectDisabledModifier())
|
|
.onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in
|
|
if !isFocused {
|
|
appChromeModel.validateAndSaveNickname()
|
|
}
|
|
}
|
|
.onSubmit {
|
|
appChromeModel.validateAndSaveNickname()
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
let countAndColor = channelPeopleCountAndColor()
|
|
let headerCountColor = countAndColor.1
|
|
let headerOtherPeersCount: Int = {
|
|
if case .location = locationChannelsModel.selectedChannel {
|
|
return peerListModel.visibleGeohashPeerCount
|
|
}
|
|
return countAndColor.0
|
|
}()
|
|
|
|
HStack(spacing: 2) {
|
|
if locationChannelsModel.gatewayEnabled {
|
|
Image(systemName: "globe")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(palette.secondary.opacity(0.8))
|
|
.headerTapTarget()
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
|
|
)
|
|
.help(
|
|
String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
|
|
)
|
|
}
|
|
|
|
if carriedMailCount > 0 {
|
|
Image(systemName: "figure.walk")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(palette.secondary.opacity(0.8))
|
|
.headerTapTarget()
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
|
|
locale: .current,
|
|
carriedMailCount
|
|
)
|
|
)
|
|
.help(
|
|
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
|
|
)
|
|
}
|
|
|
|
if appChromeModel.hasUnreadPrivateMessages {
|
|
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
|
Image(systemName: "envelope.fill")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(Color.orange)
|
|
.headerTapTarget()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button")
|
|
)
|
|
}
|
|
|
|
if case .mesh = locationChannelsModel.selectedChannel,
|
|
locationChannelsModel.permissionState == .authorized {
|
|
Button(action: {
|
|
locationChannelsModel.enableAndRefresh()
|
|
notesGeohash = locationChannelsModel.currentBuildingGeohash
|
|
showLocationNotes = true
|
|
}) {
|
|
Image(systemName: "note.text")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(Color.orange.opacity(0.8))
|
|
.headerTapTarget()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
|
|
)
|
|
}
|
|
|
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
|
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
|
|
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
|
.font(.bitchatSystem(size: 12))
|
|
.headerTapTarget()
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"),
|
|
locale: .current,
|
|
channel.geohash
|
|
)
|
|
)
|
|
}
|
|
|
|
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
|
|
let badgeText: String = {
|
|
switch locationChannelsModel.selectedChannel {
|
|
case .mesh: return "#mesh"
|
|
case .location(let channel): return "#\(channel.geohash)"
|
|
}
|
|
}()
|
|
let badgeColor: Color = {
|
|
switch locationChannelsModel.selectedChannel {
|
|
case .mesh:
|
|
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
|
case .location:
|
|
return palette.locationAccent
|
|
}
|
|
}()
|
|
|
|
Text(badgeText)
|
|
.bitchatFont(size: 14)
|
|
.foregroundColor(badgeColor)
|
|
.lineLimit(headerLineLimit)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.layoutPriority(2)
|
|
.padding(.horizontal, 6)
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
.accessibilityLabel(
|
|
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
showSidebar.toggle()
|
|
}
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "person.2.fill")
|
|
.font(.system(size: headerPeerIconSize, weight: .regular))
|
|
Text("\(headerOtherPeersCount)")
|
|
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
|
|
.accessibilityHidden(true)
|
|
}
|
|
.foregroundColor(headerCountColor)
|
|
.lineLimit(headerLineLimit)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.padding(.leading, 6)
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(
|
|
String(
|
|
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
|
|
locale: .current,
|
|
headerOtherPeersCount
|
|
)
|
|
)
|
|
// Connected-vs-nobody is otherwise encoded only in the icon's
|
|
// color; say it.
|
|
.accessibilityValue(
|
|
headerPeersReachable
|
|
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
|
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
|
)
|
|
}
|
|
.layoutPriority(3)
|
|
.sheet(isPresented: $showVerifySheet) {
|
|
VerificationSheetView(isPresented: $showVerifySheet)
|
|
.environmentObject(verificationModel)
|
|
}
|
|
}
|
|
// Fixed height is load-bearing: children fill the bar with
|
|
// .frame(maxHeight: .infinity) tap targets, so an open-ended
|
|
// minHeight lets the header expand to swallow the whole screen.
|
|
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
|
|
// Type.
|
|
.frame(height: headerHeight)
|
|
.padding(.horizontal, 12)
|
|
.onReceive(CourierStore.shared.$carriedCount) { count in
|
|
carriedMailCount = count
|
|
}
|
|
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
|
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
|
.environmentObject(locationChannelsModel)
|
|
.environmentObject(peerListModel)
|
|
}
|
|
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
|
notesGeohash = nil
|
|
}) {
|
|
Group {
|
|
if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
|
|
LocationNotesView(
|
|
geohash: geohash,
|
|
senderNickname: appChromeModel.nickname
|
|
)
|
|
.environmentObject(locationChannelsModel)
|
|
} else {
|
|
ContentLocationNotesUnavailableView(
|
|
showLocationNotes: $showLocationNotes,
|
|
headerHeight: headerHeight
|
|
)
|
|
.environmentObject(locationChannelsModel)
|
|
}
|
|
}
|
|
.onAppear {
|
|
locationChannelsModel.enableLocationChannels()
|
|
locationChannelsModel.beginLiveRefresh()
|
|
}
|
|
.onDisappear {
|
|
locationChannelsModel.endLiveRefresh()
|
|
}
|
|
.onChange(of: locationChannelsModel.availableChannels) { channels in
|
|
if let current = channels.first(where: { $0.level == .building })?.geohash,
|
|
notesGeohash != current {
|
|
notesGeohash = current
|
|
#if os(iOS)
|
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
|
generator.prepare()
|
|
generator.impactOccurred()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
|
}
|
|
.onChange(of: locationChannelsModel.selectedChannel) { _ in
|
|
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
|
}
|
|
.onChange(of: locationChannelsModel.permissionState) { _ in
|
|
locationChannelsModel.refreshMeshChannelsIfNeeded()
|
|
}
|
|
.alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) {
|
|
Button("common.ok", role: .cancel) {}
|
|
} message: {
|
|
Text("content.alert.screenshot.message")
|
|
}
|
|
.themedChromePanel(edge: .top)
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
/// Expands a small header icon to a comfortably tappable, full-bar-height
|
|
/// hit area without changing its visual size.
|
|
func headerTapTarget() -> some View {
|
|
frame(minWidth: 30, maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
private extension ContentHeaderView {
|
|
var headerLineLimit: Int? {
|
|
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
|
}
|
|
|
|
/// Whether anyone is actually reachable on the current channel — the
|
|
/// state the count icon's color encodes visually.
|
|
var headerPeersReachable: Bool {
|
|
switch locationChannelsModel.selectedChannel {
|
|
case .location:
|
|
return peerListModel.visibleGeohashPeerCount > 0
|
|
case .mesh:
|
|
return peerListModel.connectedMeshPeerCount > 0
|
|
}
|
|
}
|
|
|
|
func channelPeopleCountAndColor() -> (Int, Color) {
|
|
switch locationChannelsModel.selectedChannel {
|
|
case .location:
|
|
let count = peerListModel.visibleGeohashPeerCount
|
|
return (count, count > 0 ? palette.locationAccent : palette.secondary)
|
|
case .mesh:
|
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
|
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
|
|
return (peerListModel.reachableMeshPeerCount, color)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ContentLocationNotesUnavailableView: View {
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
@ThemedPalette private var palette
|
|
|
|
@Binding var showLocationNotes: Bool
|
|
|
|
let headerHeight: CGFloat
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
Text("content.notes.title")
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
Spacer()
|
|
SheetCloseButton { showLocationNotes = false }
|
|
.foregroundColor(palette.primary)
|
|
}
|
|
.frame(minHeight: headerHeight)
|
|
.padding(.horizontal, 12)
|
|
.themedChromePanel(edge: .top)
|
|
Text("content.notes.location_unavailable")
|
|
.bitchatFont(size: 14)
|
|
.foregroundColor(palette.secondary)
|
|
Button("content.location.enable") {
|
|
locationChannelsModel.enableAndRefresh()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
Spacer()
|
|
}
|
|
.themedSheetBackground()
|
|
.foregroundColor(palette.primary)
|
|
}
|
|
}
|