mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 11:05:19 +00:00
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool The nearby-notes counter used to open a live building-precision (precision-8) geohash REQ to the closest geo relays whenever the mesh public timeline was visible — a passive location side-channel with no opt-in. Now nothing subscribes until one explicit act reveals the counter for the session: tapping the new "check for notes left here" line on the empty mesh timeline (static, no network), opening the notices sheet's geo tab, or a successful /drop. The app-info setting stays the default-ON kill switch and still gates /drop. Subscription hygiene alongside: - LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the main actor like the timer teardown) instead of only invalidating timers. - New refcounted LocationNotesPool dedupes the counter's and the notices sheet's identical 9-cell kind-1 REQs into one shared manager per geohash; both callers release-and-reacquire instead of retargeting in place, and the sheet releases its ref on dismissal. The reveal affordance is localized across all 29 catalog locales, and new NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool refcounting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only Fixes the verified review findings on the tap-to-reveal PR: - Permission dead-end: the "check for notes left here" hint now renders only when location permission is already authorized (it never prompts). Previously a location-denied install could tap it, flip the sticky revealed flag, and get nothing for the rest of the session because retarget() guards on authorization. Predicate lives on NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive to the published permission state. - Building-cell sampling: background geohash sampling subscribed geo-sample-<gh> for every regional level including the precision-8 building cell, pre-reveal — contradicting the claim that nothing building-precision hits a relay before the explicit act. GeoChannelCoordinator now excludes the building level until NearbyNotesCounter.revealed (injectable publisher for tests); coarser levels keep the nearby-conversation hint and participant counts working, and bookmarks stay exempt (bookmarking is explicit). - Implicit reveal: opening the notices sheet no longer reveals — the sheet auto-lands on the geo tab whenever a location channel is selected, so browsing a remote geohash and opening notices revealed the LOCAL building subscription. reveal() now fires only on the person actively picking the geo segment, and only when the sheet has a geo scope (the empty-mesh hint tap and /drop stay as before). - Subscription hygiene: switching the sheet geo → mesh releases the pooled notes manager (the REQ was left streaming behind the mesh board); switching back re-acquires from the pool. The dismissal release stays balanced — liveGeoManager is nil after the tab-switch release. - VoiceOver: the hint button exposes the plain localized action text instead of the decorated "* 📍 … *" label. - Removed LocationNotesManager.setGeohash: zero callers, and calling it on a pooled instance would corrupt the pool's keying and refcounts. New tests: hint permission gate, explicit-geo-tab reveal contract, building-cell sampling exclusion before/after reveal, pool release/re-acquire round trip. Full suite (1467) green; iOS simulator build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the snapshot -> allPeers binding crosses the mock transport's unstructured Task, UnifiedPeerService.updatePeers, a receive(on: main), and another Task { @MainActor } in bindPeerService — all contending with every parallel worker. On the failing runner the whole suite took 10.1s (usually ~4.4s locally), so the positive 5s defaultTimeout wait lost the race. That's exactly the case TestConstants.longTimeout documents; passing runs return as soon as the condition holds and never pay it. Not caused by the tap-to-reveal changes: nothing on that pipeline was touched, and 20 full parallel-suite loops each on the branch and on origin/main reproduce zero failures locally. The two sibling waits on the same pipeline in this file get the same timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
9.2 KiB
Swift
218 lines
9.2 KiB
Swift
//
|
|
// MeshEmptyStateView.swift
|
|
// bitchat
|
|
//
|
|
// The empty mesh timeline, upgraded from a dead end into a live surface:
|
|
// a sonar shows the radio scanning, the daily sightings tally proves the
|
|
// spot isn't dead, the liveliest nearby geohash conversation is one tap
|
|
// away, and notes left at this place surface when there are any.
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MeshEmptyStateView: View {
|
|
/// Visible chat height to fill; the radar centers in the space left
|
|
/// below the narration. Zero (previews) keeps a compact layout.
|
|
var fillHeight: CGFloat = 0
|
|
/// Ambient-footer mode, appended below archived echoes: skips the
|
|
/// intro/help narration (the timeline isn't empty) and shrinks the
|
|
/// radar, keeping the sightings tally and the live hints visible.
|
|
var compact: Bool = false
|
|
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
@EnvironmentObject private var peerListModel: PeerListModel
|
|
@ObservedObject private var activityTracker = GeohashChatActivityTracker.shared
|
|
@ObservedObject private var sightingsTracker = MeshSightingsTracker.shared
|
|
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
|
|
|
|
@ThemedPalette private var palette
|
|
|
|
/// The activity window is evaluated at render time; without new events
|
|
/// nothing would trigger a re-render, so a stale "people are talking"
|
|
/// hint could linger. A slow tick keeps the hints and relative times
|
|
/// honest.
|
|
@State private var refreshTick = 0
|
|
private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
|
|
|
private enum Strings {
|
|
static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")
|
|
static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")
|
|
static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today")
|
|
static let checkNotes = String(localized: "content.empty.check_notes", comment: "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs")
|
|
|
|
static func sightingsMany(_ count: Int) -> String {
|
|
String(
|
|
format: String(localized: "content.empty.sightings_many", comment: "Empty mesh timeline stat counting devices that came within range today"),
|
|
locale: .current,
|
|
count
|
|
)
|
|
}
|
|
|
|
static func activityOne(_ geohash: String) -> String {
|
|
String(
|
|
format: String(localized: "content.empty.activity_one", comment: "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash"),
|
|
locale: .current,
|
|
geohash
|
|
)
|
|
}
|
|
|
|
static func activityMany(_ geohash: String) -> String {
|
|
String(
|
|
format: String(localized: "content.empty.activity_many", comment: "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash"),
|
|
locale: .current,
|
|
geohash
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
/// The radar means "searching for people": once anyone is connected or
|
|
/// reachable on the mesh, the search is over and the sweep goes away.
|
|
private var isSearchingForPeers: Bool {
|
|
peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if compact {
|
|
if isSearchingForPeers {
|
|
radarBlock
|
|
}
|
|
if let conversation = nearbyConversation {
|
|
conversationHint(conversation)
|
|
}
|
|
if showsCheckNotesHint {
|
|
checkNotesHint
|
|
}
|
|
} else {
|
|
// The radar + tally already say "scanning, nobody yet", so
|
|
// the narration stays to two lines with the live hint after
|
|
// them, not wedged in between.
|
|
narrationLine(Strings.meshIntro)
|
|
narrationLine(Strings.switchHint)
|
|
if let conversation = nearbyConversation {
|
|
conversationHint(conversation)
|
|
}
|
|
if showsCheckNotesHint {
|
|
checkNotesHint
|
|
}
|
|
|
|
// The radar centers in whatever space is left below the
|
|
// text — the flexible spacers split it evenly.
|
|
if isSearchingForPeers {
|
|
Spacer(minLength: 24)
|
|
radarBlock
|
|
Spacer(minLength: 12)
|
|
}
|
|
}
|
|
}
|
|
.frame(minHeight: compact ? 0 : fillHeight, alignment: .top)
|
|
.onReceive(refreshTimer) { _ in
|
|
refreshTick += 1
|
|
// Roll the tally over if the local day changed while idle.
|
|
sightingsTracker.refreshForDisplay()
|
|
}
|
|
}
|
|
|
|
/// The radar with today's tally as its caption — the stat belongs to
|
|
/// the scanning visual, not the narration lines.
|
|
private var radarBlock: some View {
|
|
VStack(spacing: 4) {
|
|
MeshRadarView(height: compact ? 44 : 72)
|
|
if sightingsTracker.todayCount > 0 {
|
|
Text(verbatim: sightingsText)
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(palette.secondary.opacity(0.8))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private extension MeshEmptyStateView {
|
|
var nearbyConversation: NearbyConversation? {
|
|
activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels)
|
|
}
|
|
|
|
/// Tap-to-reveal: the nearby-notes counter never subscribes on its own —
|
|
/// looking at the mesh timeline must not open a building-precision relay
|
|
/// REQ (a passive location side-channel). This static line is the one
|
|
/// explicit act that unlocks it; nothing touches the network until the
|
|
/// tap. It only renders when location permission is already granted
|
|
/// (the tap never prompts, so without permission it would dead-end
|
|
/// silently). Once revealed it yields to today's live strip and count,
|
|
/// and the app-info setting stays the kill switch.
|
|
var showsCheckNotesHint: Bool {
|
|
nearbyNotes.offersRevealHint(permissionState: locationChannelsModel.permissionState)
|
|
}
|
|
|
|
var checkNotesHint: some View {
|
|
Button {
|
|
NearbyNotesCounter.shared.reveal()
|
|
} label: {
|
|
actionLine("📍 \(Strings.checkNotes)")
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
// The visual label carries decorative asterisks and an emoji; expose
|
|
// just the localized action text to assistive tech.
|
|
.accessibilityLabel(Strings.checkNotes)
|
|
}
|
|
|
|
var sightingsText: String {
|
|
sightingsTracker.todayCount == 1
|
|
? Strings.sightingsOne
|
|
: Strings.sightingsMany(sightingsTracker.todayCount)
|
|
}
|
|
|
|
func conversationHint(_ conversation: NearbyConversation) -> some View {
|
|
let headline = conversation.messageCount == 1
|
|
? Strings.activityOne(conversation.channel.geohash)
|
|
: Strings.activityMany(conversation.channel.geohash)
|
|
|
|
return Button {
|
|
locationChannelsModel.markTeleported(for: conversation.channel.geohash, false)
|
|
locationChannelsModel.select(.location(conversation.channel))
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
actionLine("💬 \(headline)")
|
|
narrationLine(" \(previewText(for: conversation.lastMessage))")
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
func previewText(for message: GeohashChatPreview) -> String {
|
|
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
|
var content = message.content
|
|
if content.count > maxLen {
|
|
content = String(content.prefix(maxLen)) + "…"
|
|
}
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .abbreviated
|
|
let ago = formatter.localizedString(for: message.timestamp, relativeTo: Date())
|
|
return "<\(message.senderName)> \(content) · \(ago)"
|
|
}
|
|
|
|
func narrationLine(_ text: String) -> some View {
|
|
emptyStateLine(text, color: palette.secondary.opacity(0.9))
|
|
}
|
|
|
|
/// Tappable lines render in the primary color so they read as actions
|
|
/// amid the grey narration.
|
|
func actionLine(_ text: String) -> some View {
|
|
emptyStateLine(text, color: palette.primary)
|
|
}
|
|
|
|
func emptyStateLine(_ text: String, color: Color) -> some View {
|
|
// Non-breaking space before the closing asterisk so a tight wrap
|
|
// can't orphan a lone "*" onto its own line.
|
|
Text(verbatim: "* \(text)\u{00A0}*")
|
|
.bitchatFont(size: 13)
|
|
.foregroundColor(color)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|