mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:45:20 +00:00
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
829 lines
34 KiB
Swift
829 lines
34 KiB
Swift
//
|
|
// NoticesView.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// The unified notices sheet behind the header's pin icon: one place for
|
|
/// everything pinned around you, with a scope toggle.
|
|
///
|
|
/// - geo: the current geohash's notices — mesh-synced board posts merged and
|
|
/// deduped with Nostr kind-1 location notes, so you also see notices from
|
|
/// people who aren't on your mesh.
|
|
/// - mesh: the mesh-local board only (empty geohash, fully offline).
|
|
struct NoticesView: View {
|
|
enum Tab: Hashable {
|
|
case geo
|
|
case mesh
|
|
}
|
|
|
|
let senderNickname: String
|
|
@ObservedObject var board: BoardManager
|
|
|
|
@ThemedPalette private var palette
|
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
|
@State private var tab: Tab
|
|
@State private var draft: String = ""
|
|
@State private var urgent = false
|
|
/// Days until the notice fades; `permanentExpiry` (geo default) means no
|
|
/// NIP-40 tag — the note stays until its relay drops it.
|
|
@State private var expiryDays: Int
|
|
/// Mirrors the app-info kill switch so its notification produces an
|
|
/// immediate presentation update as well as tearing down subscriptions.
|
|
@State private var locationNotesEnabled = LocationNotesSettings.enabled
|
|
|
|
/// Sentinel picker tag for the ∞ option (geo tab only).
|
|
private static let permanentExpiry = 0
|
|
|
|
/// Injected notes manager for tests; live use derives one per geohash.
|
|
private let notesManager: LocationNotesManager?
|
|
/// Pooled manager held by the sheet so the composer can post pure Nostr
|
|
/// notes (∞ expiry has no mesh-board copy) and the list can render them.
|
|
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
|
|
/// counter, one REQ per geohash) and released on dismissal.
|
|
@State private var liveGeoManager: LocationNotesManager?
|
|
/// Tracks only this sheet's high-accuracy refresh ownership, so repeated
|
|
/// Combine/SwiftUI invalidations do not restart CoreLocation and a
|
|
/// revocation or kill-switch transition balances the begin call once.
|
|
@State private var ownsLiveGeoRefresh = false
|
|
|
|
init(
|
|
senderNickname: String,
|
|
board: BoardManager,
|
|
initialTab: Tab,
|
|
notesManager: LocationNotesManager? = nil
|
|
) {
|
|
self.senderNickname = senderNickname
|
|
self.board = board
|
|
self.notesManager = notesManager
|
|
_tab = State(initialValue: initialTab)
|
|
_expiryDays = State(initialValue: initialTab == .geo ? Self.permanentExpiry : 7)
|
|
}
|
|
|
|
private var activeNotesManager: LocationNotesManager? {
|
|
notesManager ?? liveGeoManager
|
|
}
|
|
|
|
/// The one explicit act inside the sheet that unlocks the passive
|
|
/// nearby-notes counter: the person actively picking the geo segment
|
|
/// while the sheet has a geo scope. Landing on the geo tab via the
|
|
/// sheet's initial selection (auto-derived from the current channel —
|
|
/// e.g. browsing a remote geohash) is not an act toward the LOCAL
|
|
/// building cell and must not reveal it.
|
|
static func revealsNearbyNotes(onSwitchingTo tab: Tab, geoGeohash: String?) -> Bool {
|
|
tab == .geo && geoGeohash != nil
|
|
}
|
|
|
|
struct GeoSessionState {
|
|
let manager: LocationNotesManager?
|
|
let ownsLiveRefresh: Bool
|
|
}
|
|
|
|
enum GeoPresentationState: Equatable {
|
|
case disabled
|
|
case locationUnavailable
|
|
case available(String)
|
|
}
|
|
|
|
static func geoPresentationState(notesEnabled: Bool, geohash: String?) -> GeoPresentationState {
|
|
guard notesEnabled else { return .disabled }
|
|
guard let geohash else { return .locationUnavailable }
|
|
return .available(geohash)
|
|
}
|
|
|
|
static func composerGeohash(tab: Tab, notesEnabled: Bool, geoGeohash: String?) -> String? {
|
|
switch tab {
|
|
case .geo:
|
|
guard case .available(let geohash) = geoPresentationState(
|
|
notesEnabled: notesEnabled,
|
|
geohash: geoGeohash
|
|
) else {
|
|
return nil
|
|
}
|
|
return geohash
|
|
case .mesh:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
/// Reconciles both privacy-sensitive resources owned by the sheet: the
|
|
/// high-accuracy CoreLocation refresh and the precise notes REQ. Kept as
|
|
/// a callback-driven function so permission and kill-switch transitions
|
|
/// can be regression tested without presenting SwiftUI.
|
|
@MainActor
|
|
static func reconcileGeoSession(
|
|
tab: Tab,
|
|
needsDeviceLocation: Bool,
|
|
permissionState: LocationChannelManager.PermissionState,
|
|
notesEnabled: Bool,
|
|
geohash: String?,
|
|
manager: LocationNotesManager?,
|
|
ownsLiveRefresh: Bool,
|
|
beginLiveRefresh: () -> Void,
|
|
endLiveRefresh: () -> Void,
|
|
acquire: (String) -> LocationNotesManager,
|
|
release: (LocationNotesManager?) -> Void
|
|
) -> GeoSessionState {
|
|
let geoTabActive = tab == .geo && notesEnabled
|
|
let wantsLiveRefresh = geoTabActive && needsDeviceLocation && permissionState == .authorized
|
|
|
|
if wantsLiveRefresh != ownsLiveRefresh {
|
|
if wantsLiveRefresh {
|
|
beginLiveRefresh()
|
|
} else {
|
|
endLiveRefresh()
|
|
}
|
|
}
|
|
|
|
// A selected location channel is an explicit remote/teleported scope
|
|
// and remains usable without device permission. Only device-derived
|
|
// scope requires current authorization.
|
|
let mayUseNotes = geoTabActive &&
|
|
(!needsDeviceLocation || permissionState == .authorized)
|
|
guard mayUseNotes, let geohash else {
|
|
if manager != nil {
|
|
release(manager)
|
|
}
|
|
return GeoSessionState(manager: nil, ownsLiveRefresh: wantsLiveRefresh)
|
|
}
|
|
|
|
if let manager {
|
|
if manager.geohash != geohash.lowercased() {
|
|
// Pooled managers are shared; never retarget one in place.
|
|
release(manager)
|
|
return GeoSessionState(
|
|
manager: acquire(geohash),
|
|
ownsLiveRefresh: wantsLiveRefresh
|
|
)
|
|
}
|
|
if manager.state == .idle {
|
|
manager.refresh()
|
|
}
|
|
return GeoSessionState(manager: manager, ownsLiveRefresh: wantsLiveRefresh)
|
|
}
|
|
|
|
return GeoSessionState(
|
|
manager: acquire(geohash),
|
|
ownsLiveRefresh: wantsLiveRefresh
|
|
)
|
|
}
|
|
|
|
private func reconcileGeoSession(notesEnabled: Bool? = nil) {
|
|
let notesEnabled = notesEnabled ?? locationNotesEnabled
|
|
let next = Self.reconcileGeoSession(
|
|
tab: tab,
|
|
needsDeviceLocation: geoTabNeedsDeviceLocation,
|
|
permissionState: locationChannelsModel.permissionState,
|
|
notesEnabled: notesEnabled,
|
|
geohash: geoGeohash,
|
|
manager: liveGeoManager,
|
|
ownsLiveRefresh: ownsLiveGeoRefresh,
|
|
beginLiveRefresh: {
|
|
locationChannelsModel.enableLocationChannels()
|
|
locationChannelsModel.beginLiveRefresh()
|
|
},
|
|
endLiveRefresh: { locationChannelsModel.endLiveRefresh() },
|
|
acquire: { geohash in
|
|
notesManager ?? LocationNotesPool.shared.acquire(geohash)
|
|
},
|
|
release: { manager in
|
|
guard notesManager == nil else { return }
|
|
LocationNotesPool.shared.release(manager)
|
|
}
|
|
)
|
|
// A test-injected manager is owned by the caller, not by the pool or
|
|
// this view's lifecycle state.
|
|
liveGeoManager = notesManager == nil ? next.manager : nil
|
|
ownsLiveGeoRefresh = next.ownsLiveRefresh
|
|
}
|
|
|
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
|
|
|
/// The geohash the geo tab is scoped to: the selected location channel,
|
|
/// or the device's building geohash when chatting on mesh.
|
|
private var geoGeohash: String? {
|
|
if case .location(let channel) = locationChannelsModel.selectedChannel {
|
|
return channel.geohash
|
|
}
|
|
guard locationChannelsModel.permissionState == .authorized else {
|
|
return nil
|
|
}
|
|
return locationChannelsModel.currentBuildingGeohash
|
|
}
|
|
|
|
/// The geo scope comes from device location only when no location channel
|
|
/// is selected; that's the case that needs the location machinery.
|
|
private var geoTabNeedsDeviceLocation: Bool {
|
|
if case .location = locationChannelsModel.selectedChannel { return false }
|
|
return true
|
|
}
|
|
|
|
private var activeGeohash: String? {
|
|
Self.composerGeohash(
|
|
tab: tab,
|
|
notesEnabled: locationNotesEnabled,
|
|
geoGeohash: geoGeohash
|
|
)
|
|
}
|
|
|
|
enum Strings {
|
|
static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
|
|
static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
|
|
static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
|
|
static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
|
|
// The pre-merge location-notes explainer, reused so its existing
|
|
// translations carry over.
|
|
static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
|
|
static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
|
|
static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
|
|
static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
|
|
static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
|
|
static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
|
|
static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
|
|
static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
|
|
static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
|
|
static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
|
|
static let permanentOption = String(localized: "notices.expiry.permanent", defaultValue: "permanent", comment: "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker")
|
|
static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
|
|
static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
|
|
static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
|
|
static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
|
|
static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
|
|
static let locationNotesTitle: LocalizedStringKey = "app_info.location.notes.title"
|
|
static let locationNotesDescription: LocalizedStringKey = "app_info.location.notes.description"
|
|
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
|
static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays"
|
|
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
|
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
|
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
|
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
|
|
|
static func expiryDaysOption(_ days: Int) -> String {
|
|
String(
|
|
format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
|
|
locale: .current,
|
|
days
|
|
)
|
|
}
|
|
|
|
static func fades(_ expiresAt: Date) -> String {
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .abbreviated
|
|
return String(
|
|
format: String(localized: "notices.fades", defaultValue: "fades %@", comment: "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'"),
|
|
locale: .current,
|
|
formatter.localizedString(for: expiresAt, relativeTo: Date())
|
|
)
|
|
}
|
|
|
|
static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
|
|
let base = String(
|
|
format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
|
|
locale: .current,
|
|
author, content
|
|
)
|
|
return urgent ? "\(urgentBadge), \(base)" : base
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
headerSection
|
|
contentSection
|
|
if activeGeohash != nil {
|
|
composer
|
|
}
|
|
}
|
|
.themedSurface()
|
|
#if os(macOS)
|
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
|
#endif
|
|
.themedSheetBackground()
|
|
.onAppear {
|
|
reconcileGeoSession()
|
|
}
|
|
.onChange(of: tab) { newTab in
|
|
if newTab == .geo {
|
|
if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
|
|
NearbyNotesCounter.shared.reveal()
|
|
}
|
|
}
|
|
reconcileGeoSession()
|
|
// Each tab keeps its natural default: geo notes stay until
|
|
// deleted (∞), mesh board posts fade within a week.
|
|
expiryDays = newTab == .geo ? Self.permanentExpiry : 7
|
|
urgent = false
|
|
}
|
|
// Catches both grant and revocation. Revocation must balance the live
|
|
// refresh and release a device-derived building REQ immediately.
|
|
.onChange(of: locationChannelsModel.permissionState) { _ in
|
|
reconcileGeoSession()
|
|
}
|
|
.onChange(of: geoGeohash) { _ in
|
|
reconcileGeoSession()
|
|
}
|
|
.onChange(of: geoTabNeedsDeviceLocation) { _ in
|
|
reconcileGeoSession()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: LocationNotesSettings.didChangeNotification)) { _ in
|
|
let enabled = LocationNotesSettings.enabled
|
|
locationNotesEnabled = enabled
|
|
reconcileGeoSession(notesEnabled: enabled)
|
|
}
|
|
.onDisappear {
|
|
if ownsLiveGeoRefresh {
|
|
locationChannelsModel.endLiveRefresh()
|
|
ownsLiveGeoRefresh = false
|
|
}
|
|
if notesManager == nil {
|
|
LocationNotesPool.shared.release(liveGeoManager)
|
|
}
|
|
liveGeoManager = nil
|
|
}
|
|
}
|
|
|
|
private var headerSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 12) {
|
|
Text(verbatim: scopeTitle)
|
|
.bitchatFont(size: 18)
|
|
Spacer()
|
|
SheetCloseButton { dismiss() }
|
|
.accessibilityLabel(Strings.closeHint)
|
|
}
|
|
Picker(Strings.scopePicker, selection: $tab) {
|
|
Text(Strings.geoTab).tag(Tab.geo)
|
|
Text(Strings.meshTab).tag(Tab.mesh)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.accessibilityLabel(Strings.scopePicker)
|
|
Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 12)
|
|
.themedSurface()
|
|
}
|
|
|
|
private var scopeTitle: String {
|
|
switch tab {
|
|
case .mesh:
|
|
return "\(Strings.title) @ #mesh"
|
|
case .geo:
|
|
if let geohash = geoGeohash {
|
|
return "\(Strings.title) @ #\(geohash)"
|
|
}
|
|
return Strings.title
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var contentSection: some View {
|
|
switch tab {
|
|
case .mesh:
|
|
NoticesList(
|
|
items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
|
|
showsSource: false,
|
|
board: board,
|
|
notesManager: nil
|
|
)
|
|
case .geo:
|
|
switch Self.geoPresentationState(
|
|
notesEnabled: locationNotesEnabled,
|
|
geohash: geoGeohash
|
|
) {
|
|
case .disabled:
|
|
locationNotesDisabledSection
|
|
case .available(let geohash):
|
|
if let manager = activeNotesManager {
|
|
GeoNoticesList(geohash: geohash, board: board, manager: manager)
|
|
} else {
|
|
// Manager is created on appear; visible for one frame.
|
|
Color.clear
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onAppear { reconcileGeoSession() }
|
|
}
|
|
case .locationUnavailable:
|
|
locationUnavailableSection
|
|
}
|
|
}
|
|
}
|
|
|
|
private var locationNotesDisabledSection: some View {
|
|
ScrollView {
|
|
Toggle(
|
|
isOn: Binding(
|
|
get: { locationNotesEnabled },
|
|
set: { enabled in
|
|
locationNotesEnabled = enabled
|
|
LocationNotesSettings.enabled = enabled
|
|
}
|
|
)
|
|
) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(Strings.locationNotesTitle)
|
|
.bitchatFont(size: 14)
|
|
Text(Strings.locationNotesDescription)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.toggleStyle(.switch)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.themedSurface()
|
|
}
|
|
|
|
private var locationUnavailableSection: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(Strings.locationUnavailable)
|
|
.bitchatFont(size: 14)
|
|
.foregroundColor(palette.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Button(Strings.enableLocation) {
|
|
locationChannelsModel.enableAndRefresh()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.themedSurface()
|
|
}
|
|
|
|
private var composer: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
TextField(Strings.placeholder, text: $draft, axis: .vertical)
|
|
.textFieldStyle(.plain)
|
|
.bitchatFont(size: 14)
|
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
|
.padding(.vertical, 6)
|
|
Button(action: send) {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.font(.bitchatSystem(size: 20))
|
|
.foregroundColor(sendEnabled ? palette.accent : .secondary)
|
|
}
|
|
.padding(.top, 2)
|
|
.buttonStyle(.plain)
|
|
.disabled(!sendEnabled)
|
|
.accessibilityLabel(Strings.send)
|
|
}
|
|
// Both tabs pick an expiry (geo notes may be ∞); urgency is a
|
|
// mesh-board concept — notes are ambient by nature.
|
|
HStack(spacing: 12) {
|
|
if tab == .mesh {
|
|
Toggle(isOn: $urgent) {
|
|
Text(Strings.urgentToggle)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(urgent ? palette.alertRed : palette.secondary)
|
|
}
|
|
.toggleStyle(.switch)
|
|
.fixedSize()
|
|
.accessibilityLabel(Strings.urgentToggle)
|
|
}
|
|
Spacer()
|
|
Text(Strings.expiryLabel)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
Picker(Strings.expiryLabel, selection: $expiryDays) {
|
|
// Mesh board posts must fade (the wire caps their
|
|
// lifetime); only relay-backed geo notes can be ∞.
|
|
if tab == .geo {
|
|
Text(verbatim: "∞")
|
|
.accessibilityLabel(Strings.permanentOption)
|
|
.tag(Self.permanentExpiry)
|
|
}
|
|
ForEach([1, 3, 7], id: \.self) { days in
|
|
Text(Strings.expiryDaysOption(days)).tag(days)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
// macOS segmented pickers render their own label; the themed
|
|
// Text alongside already carries it (and accessibility keeps
|
|
// the explicit label below).
|
|
.labelsHidden()
|
|
.fixedSize()
|
|
.accessibilityLabel(Strings.expiryLabel)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.themedSurface()
|
|
.overlay(Divider(), alignment: .top)
|
|
}
|
|
|
|
private var sendEnabled: Bool {
|
|
let trimmed = draft.trimmed
|
|
return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
|
|
}
|
|
|
|
private func send() {
|
|
guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
|
|
|
|
// ∞ (geo default): a pure relay note with no NIP-40 tag. It skips
|
|
// the mesh board deliberately — a board copy must fade within days,
|
|
// which would contradict the permanence the user just picked.
|
|
if tab == .geo, expiryDays == Self.permanentExpiry {
|
|
guard let manager = activeNotesManager else { return }
|
|
manager.send(content: content, nickname: senderNickname, expiresAt: nil)
|
|
draft = ""
|
|
urgent = false
|
|
return
|
|
}
|
|
|
|
// Expiring posts go to the board and are bridged to Nostr by
|
|
// BoardManager, so mesh and internet see the same notice with the
|
|
// chosen expiry (expiresAt on mesh, NIP-40 on the bridged note).
|
|
// Urgency is mesh-only.
|
|
let sent = board.createPost(
|
|
content: content,
|
|
geohash: geohash,
|
|
urgent: tab == .mesh && urgent,
|
|
expiryDays: expiryDays,
|
|
nickname: senderNickname
|
|
)
|
|
if sent {
|
|
draft = ""
|
|
urgent = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The geo tab's list: renders the sheet-owned Nostr notes subscription
|
|
/// merged with the board posts for the same geohash. The manager lives on
|
|
/// `NoticesView` so the composer can post through the same instance (∞
|
|
/// notes local-echo into this list).
|
|
private struct GeoNoticesList: View {
|
|
let geohash: String
|
|
@ObservedObject var board: BoardManager
|
|
@ObservedObject var notesManager: LocationNotesManager
|
|
|
|
init(geohash: String, board: BoardManager, manager: LocationNotesManager) {
|
|
self.geohash = geohash.lowercased()
|
|
self.board = board
|
|
self.notesManager = manager
|
|
}
|
|
|
|
var body: some View {
|
|
NoticesList(
|
|
items: UnifiedNotices.merge(
|
|
posts: board.posts(forGeohash: geohash),
|
|
notes: notesManager.notes
|
|
),
|
|
showsSource: true,
|
|
board: board,
|
|
notesManager: notesManager
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Renders merged notices with per-source affordances: swipe-delete for own
|
|
/// items and a mesh/net badge when sources mix.
|
|
private struct NoticesList: View {
|
|
let items: [NoticeItem]
|
|
let showsSource: Bool
|
|
let board: BoardManager
|
|
let notesManager: LocationNotesManager?
|
|
|
|
@ThemedPalette private var palette
|
|
|
|
private typealias Strings = NoticesView.Strings
|
|
|
|
var body: some View {
|
|
Group {
|
|
if items.isEmpty {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
statusRows
|
|
if showEmptyState {
|
|
Text(Strings.emptyTitle)
|
|
.bitchatFont(size: 13, weight: .semibold)
|
|
Text(Strings.emptySubtitle)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
} else {
|
|
List {
|
|
statusRows
|
|
.listRowBackground(palette.background)
|
|
.listRowSeparatorTint(palette.divider)
|
|
ForEach(items) { item in
|
|
row(item)
|
|
.listRowBackground(palette.background)
|
|
.listRowSeparatorTint(palette.divider)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.themedSurface()
|
|
}
|
|
|
|
/// Notes may still be loading or unreachable; only claim "no notices yet"
|
|
/// once the sources settled.
|
|
private var showEmptyState: Bool {
|
|
guard let notesManager else { return true }
|
|
return notesManager.initialLoadComplete
|
|
&& notesManager.state != .loading
|
|
&& notesManager.state != .connecting
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusRows: some View {
|
|
if let notesManager {
|
|
if notesManager.state == .loading && !notesManager.initialLoadComplete {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text(Strings.loadingNotes)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
} else if notesManager.state == .connecting {
|
|
HStack(spacing: 10) {
|
|
ProgressView()
|
|
Text(Strings.connectingRelays)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
} else if notesManager.state == .noRelays {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(Strings.noRelaysNearby)
|
|
.bitchatFont(size: 13, weight: .semibold)
|
|
Text(Strings.relaysRetryHint)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
Button(Strings.retry) { notesManager.refresh() }
|
|
.bitchatFont(size: 12)
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.bottom, 8)
|
|
} else if let error = notesManager.errorMessage {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.bitchatFont(size: 12)
|
|
Text(error)
|
|
.bitchatFont(size: 12)
|
|
Spacer()
|
|
}
|
|
Button(Strings.dismissError) { notesManager.clearError() }
|
|
.bitchatFont(size: 12)
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func canDelete(_ item: NoticeItem) -> Bool {
|
|
switch item.source {
|
|
case .board(let post):
|
|
return board.isOwnPost(post)
|
|
case .nostr(let note):
|
|
return notesManager?.isOwnNote(note) ?? false
|
|
}
|
|
}
|
|
|
|
private func delete(_ item: NoticeItem) {
|
|
switch item.source {
|
|
case .board(let post):
|
|
// Tombstones the board post and retracts the bridged Nostr copy.
|
|
board.deletePost(post)
|
|
case .nostr(let note):
|
|
notesManager?.delete(note: note)
|
|
}
|
|
}
|
|
|
|
private func row(_ item: NoticeItem) -> some View {
|
|
let isOwn = canDelete(item)
|
|
return VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
if item.isUrgent {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.bitchatSystem(size: 11))
|
|
.foregroundColor(palette.alertRed)
|
|
Text(Strings.urgentBadge)
|
|
.bitchatFont(size: 11, weight: .semibold)
|
|
.foregroundColor(palette.alertRed)
|
|
}
|
|
Text(verbatim: "@\(item.author)")
|
|
.bitchatFont(size: 12, weight: .semibold)
|
|
Text(Self.timestampText(for: item.createdAt))
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(palette.secondary)
|
|
if let expiresAt = item.expiresAt, expiresAt > Date() {
|
|
Text(Strings.fades(expiresAt))
|
|
.bitchatFont(size: 11)
|
|
.foregroundColor(palette.secondary.opacity(0.8))
|
|
}
|
|
Spacer()
|
|
if showsSource {
|
|
sourceBadge(item)
|
|
}
|
|
if isOwn {
|
|
Button {
|
|
delete(item)
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.font(.bitchatSystem(size: 12))
|
|
.foregroundColor(palette.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Strings.deleteAction)
|
|
}
|
|
}
|
|
Text(item.content)
|
|
.bitchatFont(size: 14)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
|
|
.accessibilityActions {
|
|
if isOwn {
|
|
Button(Strings.deleteAction) { delete(item) }
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
if isOwn {
|
|
Button(role: .destructive) {
|
|
delete(item)
|
|
} label: {
|
|
Label(Strings.deleteAction, systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sourceBadge(_ item: NoticeItem) -> some View {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
|
|
.font(.bitchatSystem(size: 10))
|
|
Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
|
.bitchatFont(size: 10)
|
|
}
|
|
.foregroundColor(palette.secondary.opacity(0.8))
|
|
.accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
|
|
}
|
|
|
|
// MARK: - Timestamp Formatting
|
|
|
|
private static func timestampText(for date: Date) -> String {
|
|
let now = Date()
|
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
|
// The whole "3 hr ago" phrase must come from the formatter —
|
|
// gluing an English "ago" onto a localized duration ships the
|
|
// wrong word order to most locales ("hace 3 h", "vor 3 Std").
|
|
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
|
}
|
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
|
return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
|
|
}
|
|
|
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
return f
|
|
}()
|
|
|
|
private static let absDateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.setLocalizedDateFormatFromTemplate("MMM d")
|
|
return f
|
|
}()
|
|
|
|
private static let absDateYearFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.setLocalizedDateFormatFromTemplate("MMM d, y")
|
|
return f
|
|
}()
|
|
}
|