Merge branch 'main' into feat/courier

Resolve DeliveryStatusView conflict: adopt main's bitchatDescription
extension (tooltip + VoiceOver via body modifiers) and fold the
.carried case into it, dropping the branch's Strings enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 14:27:17 +02:00
co-authored by Claude Fable 5
69 changed files with 2562 additions and 571 deletions
+34 -2
View File
@@ -12,7 +12,10 @@ jobs:
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15
# The long steps carry tighter individual bounds (5-minute test watchdog,
# 6-minute benchmark step, 10-minute floor gate that may re-run the
# benchmarks up to twice on a noisy runner); this is the backstop.
timeout-minutes: 25
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -102,9 +105,14 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance.
# catches algorithmic regressions, never runner variance. If a metric
# still lands below floor (a saturated runner can dip one), the script
# re-runs the benchmarks — appending to the same log and keeping each
# benchmark's best value per metric — so noise clears on retry while a
# real regression fails every attempt. Floors are never lowered by this.
- name: Performance floor gate
if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
@@ -145,3 +153,27 @@ jobs:
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
build
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
lint:
name: SwiftLint (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
# privilege we can: a read-only token, and no credentials left in the
# checkout for the container to find.
permissions:
contents: read
container:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
+33
View File
@@ -0,0 +1,33 @@
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .swiftpm
- .DerivedData
- DerivedData
- build
- localPackages/*/.build
disabled_rules:
- line_length
- type_name
- identifier_name
- statement_position
- implicit_optional_initialization
- force_try
- vertical_whitespace
- for_where
- control_statement
- void_function_in_ternary
- redundant_discardable_let # SwiftUI breaks without it
# To be enabled as we fix the issues
- trailing_whitespace
- cyclomatic_complexity
- function_body_length
- function_parameter_count
- type_body_length
- file_length
- large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable(
name: "bitchat",
targets: ["bitchat"]
),
)
],
dependencies:[
dependencies: [
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
+20
View File
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message)
}
/// Resends a failed private message through the normal send path,
/// removing the failed original so the re-submission replaces it
/// instead of stacking a duplicate under the red bubble.
func resendFailedPrivateMessage(_ message: BitchatMessage) {
chatViewModel.removePrivateMessage(withID: message.id)
chatViewModel.sendMessage(message.content)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
// Mesh: block the peer's stable Noise identity resolved from the
// tapped peerID rather than re-resolving a display-name string.
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
/// the tapped peer's stable identity so the exact row is unblocked this
/// also works for offline peers, which the `/unblock <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
+7 -1
View File
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
true
}
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
}
enum TrustLevel: String, Codable {
case unknown = "unknown"
case casual = "casual"
case trusted = "trusted"
case verified = "verified"
case unknown
case casual
case trusted
case verified
}
// MARK: - Identity Cache
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -11,33 +11,38 @@ import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
// Raw values must match the aliases CommandProcessor actually accepts
// the suggestion panel is the app's only command-discovery surface, and
// suggesting a spelling the processor rejects teaches users dead ends.
case block
case clear
case help
case hug
case message = "dm"
case message = "msg"
case slap
case unblock
case who
case favorite
case unfavorite
case favorite = "fav"
case unfavorite = "unfav"
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .who:
case .clear, .help, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM {
return baseCommands + [.favorite, .unfavorite]
return baseCommands
}
return baseCommands
return baseCommands + [.favorite, .unfavorite]
}
}
+1 -1
View File
@@ -648,7 +648,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: "nip44-v2".data(using: .utf8)!,
info: Data("nip44-v2".utf8),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
@@ -132,4 +132,3 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5
case .province: return 4
case .region: return 2
}
}
}
var displayName: String {
+4 -5
View File
@@ -1326,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -2008,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2849,7 +2849,7 @@ extension BLEService {
// Notify delegate of failure
notifyUI { [weak self] in
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed")))
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
}
}
}
@@ -3108,10 +3108,9 @@ extension BLEService {
case .leave:
handleLeave(packet, from: senderID)
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
}
if forwardAlongRouteIfNeeded(packet) {
+18 -1
View File
@@ -105,11 +105,28 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default:
return .error(message: "unknown command: \(cmd)")
return .error(message: "unknown command: \(cmd) — type /help for commands")
}
}
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] — start a private chat
/who — list who's here
/clear — clear this chat
/hug @name — send a hug
/slap @name — slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name — favorites (mesh only)
/help — this list
"""
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
+1 -1
View File
@@ -86,7 +86,7 @@ final class MessageRouter {
}
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
note.userInfo?["isKeyUpdate"] is Bool {
let peerID = PeerID(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
@@ -471,7 +471,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data()
// context
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
let context = Data("bitchat-announce-v1".utf8)
out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -487,7 +487,7 @@ final class NoiseEncryptionService {
out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes
let nickData = nickname.data(using: .utf8) ?? Data()
let nickData = Data(nickname.utf8)
out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255))
// timestamp
+1 -1
View File
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() {
guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
// Permission granted
} else {
+23 -1
View File
@@ -257,7 +257,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false
}
/// Block or unblock a mesh peer by its stable Noise identity.
///
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
/// (cache / mesh session / known-peer Noise key). This works even when the
/// peer is offline including offline favorites so the exact tapped peer
/// is (un)blocked unambiguously instead of being re-resolved by a
/// display-name string that two peers could share.
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
@discardableResult
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
guard let fingerprint = getFingerprint(for: peerID) else {
SecureLogger.warning(
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
category: .session
)
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
updatePeers()
return fingerprint
}
/// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else {
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
return result
}
}
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
}
}
}
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
key: Data?
) {
guard let context else { return }
if let _ = key {
if key != nil {
if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
}
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if key != nil {
if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
}
+58 -3
View File
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
// command, these resolve and persist the block by the peer's stable
// fingerprint (derived from `peerID`), so the exact tapped peer is
// (un)blocked unambiguous across nickname collisions and functional for
// offline peers that can no longer be resolved through the mesh service.
@MainActor
func blockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
}
@MainActor
func unblockMeshPeer(peerID: PeerID, displayName: String) {
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
}
@MainActor
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
),
locale: .current,
displayName
)
)
return
}
addCommandOutput(
String(
format: String(
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
comment: "System message shown when a mesh peer is blocked or unblocked"
),
locale: .current,
displayName
)
)
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
}
@@ -1438,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
@MainActor
func handleCommand(_ command: String) {
let result = commandProcessor.process(command)
@@ -1446,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result {
case .success(let message):
if let msg = message {
addSystemMessage(msg)
addCommandOutput(msg)
}
case .error(let message):
addSystemMessage(message)
addCommandOutput(message)
case .handled:
// Command was handled, no message needed
break
}
}
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
@MainActor
private func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
addSystemMessage(content)
}
}
// MARK: - Message Reception
@MainActor
@@ -100,7 +100,7 @@ private extension ChatViewModelBootstrapper {
category: .session
)
viewModel.conversations.setDeliveryStatus(
.failed(reason: "Not delivered"),
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
forMessageID: messageID
)
}
+44 -8
View File
@@ -55,6 +55,26 @@ struct AppInfoView: View {
)
}
enum Legend {
static let title: LocalizedStringKey = "app_info.legend.title"
/// Every glyph the peer lists and headers use, in one place
/// nothing else in the app defines them.
static let items: [(icon: String, text: LocalizedStringKey)] = [
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
("globe", "app_info.legend.nostr"),
("person", "app_info.legend.offline"),
("mappin.and.ellipse", "app_info.legend.location_nearby"),
("face.dashed", "app_info.legend.teleported"),
("lock.fill", "app_info.legend.encrypted"),
("lock.slash", "app_info.legend.encryption_failed"),
("checkmark.seal.fill", "app_info.legend.verified"),
("star.fill", "app_info.legend.favorite"),
("envelope.fill", "app_info.legend.unread"),
("nosign", "app_info.legend.blocked")
]
}
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -118,14 +138,8 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("app_info.close")
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
}
}
}
@@ -202,6 +216,28 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions)
}
// Symbols legend
VStack(alignment: .leading, spacing: 10) {
SectionHeader(Strings.Legend.title)
ForEach(Strings.Legend.items, id: \.icon) { item in
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.frame(width: 30)
Text(item.text)
.bitchatFont(size: 13)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.accessibilityElement(children: .combine)
}
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
@@ -14,29 +14,46 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String
/// The command already typed in full, once arguments have begun.
private var typedCommandAlias: String? {
guard messageText.hasPrefix("/"),
let spaceIndex = messageText.firstIndex(of: " ")
else { return nil }
return String(messageText[..<spaceIndex]).lowercased()
}
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
guard messageText.hasPrefix("/") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
// While arguments are being typed, keep the matched command's usage
// row visible instead of vanishing at the first space.
if let typed = typedCommandAlias {
return commands.filter { $0.alias == typed && $0.placeholder != nil }
}
return commands.filter { command in
command.alias.starts(with: messageText.lowercased())
}
}
var body: some View {
// Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row
// off-center.
if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
// In usage-reminder mode the row is informational; an
// insert here would wipe the arguments being typed.
guard !isUsageReminder else { return }
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -9,6 +9,47 @@
import SwiftUI
import BitFoundation
extension DeliveryStatus {
/// Localized, user-facing description of the status. Used for macOS
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
case .read(let nickname, _):
return String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
case .failed(let reason):
return String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
case .partiallyDelivered(let reached, let total):
return String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
}
struct DeliveryStatusView: View {
@ThemedPalette private var palette
let status: DeliveryStatus
@@ -19,66 +60,34 @@ struct DeliveryStatusView: View {
private var secondaryTextColor: Color { palette.secondary }
private enum Strings {
static func delivered(to nickname: String) -> String {
String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
}
static func read(by nickname: String) -> String {
String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
}
static func failed(_ reason: String) -> String {
String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
}
static var carried: String {
String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Tooltip for messages handed to a courier for physical delivery")
}
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
// MARK: - Body
var body: some View {
statusGlyph
.help(status.bitchatDescription)
.accessibilityElement(children: .ignore)
.accessibilityLabel(status.bitchatDescription)
}
@ViewBuilder
private var statusGlyph: some View {
switch status {
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sent:
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .carried:
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
.help(Strings.carried)
case .delivered(let nickname, _):
case .delivered:
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
@@ -86,24 +95,22 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10))
}
.foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
case .read:
// Filled variant so read vs delivered is legible without color.
HStack(spacing: 0) {
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
}
.foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed(let reason):
case .failed:
Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) {
Image(systemName: "checkmark")
@@ -112,7 +119,6 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10)
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
}
}
}
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary }
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
@@ -0,0 +1,29 @@
//
// SheetCloseButton.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// The close "X" every sheet and header shares. One glyph size and weight
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
/// so existing header metrics don't move, and a hit target extended to 44pt
/// per platform guidelines. Tint comes from the environment, so callers keep
/// their own foreground color.
struct SheetCloseButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
}
}
+53 -5
View File
@@ -12,6 +12,7 @@ import BitFoundation
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
@@ -24,6 +25,7 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) {
self.message = message
@@ -35,19 +37,59 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) {
// Baseline alignment keeps the lock and delivery glyphs on the
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
}
}
@@ -60,7 +102,7 @@ struct TextMessageView: View {
else { expandedMessageIDs.insert(message.id) }
}
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue)
.foregroundColor(palette.accentBlue)
.padding(.top, 4)
}
@@ -78,6 +120,12 @@ struct TextMessageView: View {
.padding(.leading, 2)
}
}
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
}
}
+67 -8
View File
@@ -6,6 +6,7 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -60,10 +60,8 @@ struct ContentComposerView: View {
TextField(
"",
text: $messageText,
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(palette.secondary.opacity(0.6))
prompt: Text(placeholderText)
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.bitchatFont(size: 15)
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// States where a message will land: the DM partner's name for private
/// chats, the channel (and its public nature) otherwise so a stressed
/// user never has to guess who can read what they're typing.
var placeholderText: String {
if let header = privateConversationModel.selectedHeaderState {
// A geohash-DM display name already carries its own "#geohash/@name"
// form, so it must not get another "@" prefix; a mesh nickname does.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
}
}
var recordingIndicator: some View {
HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill")
@@ -158,7 +183,19 @@ private extension ContentComposerView {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
.accessibilityLabel(
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
imagePickerSourceType = .camera
showImagePicker = true
}
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
@@ -167,7 +204,9 @@ private extension ContentComposerView {
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel("Choose photo")
.accessibilityLabel(
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif
}
@@ -209,7 +248,27 @@ private extension ContentComposerView {
}
)
)
.accessibilityLabel("Hold to record a voice note")
.accessibilityLabel(
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
// give it a start/stop toggle as the default action.
.accessibilityAction {
if voiceRecordingVM.state.isActive {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
} else {
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
}
}
func sendButtonView(enabled: Bool) -> some View {
+38 -11
View File
@@ -36,6 +36,16 @@ struct ContentHeaderView: View {
.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: "@")
@@ -203,6 +213,13 @@ struct ContentHeaderView: View {
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) {
@@ -210,6 +227,11 @@ struct ContentHeaderView: View {
.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
@@ -289,14 +311,25 @@ private extension ContentHeaderView {
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 : Color.secondary)
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 : Color.secondary
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
return (peerListModel.reachableMeshPeerCount, color)
}
}
@@ -316,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
SheetCloseButton { showLocationNotes = false }
.foregroundColor(palette.primary)
}
.frame(height: headerHeight)
.frame(minHeight: headerHeight)
.padding(.horizontal, 12)
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
+104 -20
View File
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
// .help maps to the accessibility *hint* on iOS, so the
// button still needs a spoken name.
.accessibilityLabel(
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
)
.help(
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
Button(action: {
SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss()
showSidebar = false
showVerifySheet = false
privateConversationModel.endConversation()
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
.bitchatFont(size: 12)
} else {
Text(activeText)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
},
onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID)
},
onToggleBlock: { peer in
if peer.isBlocked {
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
} else {
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
}
}
)
}
@@ -333,6 +340,9 @@ private struct ContentPrivateChatSheetView: View {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
// Same visual box + 44pt hit target as SheetCloseButton.
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0)
Button(action: {
SheetCloseButton {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
// minHeight so scaled text at accessibility sizes grows the
// bar instead of clipping inside it.
.frame(minHeight: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.themedSurface()
.modifier(PrivateHeaderChrome())
}
MessageListView(
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
Divider()
}
privacyCaption
#if os(iOS)
ContentComposerView(
messageText: $messageText,
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
}
)
}
/// Persistent one-line reminder that this composer feeds a private
/// conversation the DM sheet otherwise renders identically to the
/// public timeline. Claims end-to-end encryption only once the session
/// is actually secured.
private var privacyCaption: some View {
HStack(spacing: 5) {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 9))
// Optical centering: lock.fill's ink is bottom-heavy, so
// geometric centering reads low next to the caption text.
.offset(y: -1)
Text(verbatim: privacyCaptionText)
.bitchatFont(size: 11, weight: .medium)
}
.foregroundColor(Color.orange)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
// The orange text is signature enough; a tinted band here reads as a
// stray strip against the untinted composer chrome below it, so the
// caption sits on the same surface as the rest of the bottom chrome.
.themedSurface()
.accessibilityElement(children: .combine)
}
private var privacyCaptionText: String {
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let noiseSecured: Bool = {
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
case .noiseSecured, .noiseVerified: return true
default: return false
}
}()
if isGeoDM || noiseSecured {
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
}
}
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
/// over an opaque themed surface. Glass gets the same floating panel as the
/// main header instead: an orange wash over the backdrop gradient reads as a
/// muddy gray-beige band, and the DM signature is already carried by the
/// orange lock, caption, and composer accents.
private struct PrivateHeaderChrome: ViewModifier {
@Environment(\.appTheme) private var theme
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.themedChromePanel(edge: .top)
} else {
// Orange tint before themedSurface so it layers in front of the
// opaque themed background rather than behind it.
content
.background(Color.orange.opacity(0.06))
.themedSurface()
}
}
}
private struct ContentPrivateHeaderInfoButton: View {
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
EmptyView()
// Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
}
Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
// Middle truncation keeps the identity suffix visible on
// long nicknames instead of wrapping into the fixed-height
// header.
.lineLimit(1)
.truncationMode(.middle)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
// Optical centering: the lock glyphs' ink is bottom-heavy
// (solid body, thin shackle), so geometric centering reads
// ~1pt low next to the name. The seal badge is symmetric
// and needs no lift.
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(height: headerHeight)
.frame(minHeight: headerHeight)
}
}
+5 -8
View File
@@ -54,11 +54,8 @@ struct FingerprintView: View {
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
}
.padding()
@@ -83,7 +80,7 @@ struct FingerprintView: View {
Spacer()
}
.padding()
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
// Their fingerprint
@@ -101,7 +98,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
@@ -135,7 +132,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(Color.gray.opacity(0.1))
.background(palette.secondary.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
+45 -1
View File
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block"
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
}
var body: some View {
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
Image(systemName: icon)
// Size 10 to match the mesh rows' leading glyphs
// both lists share the sidebar.
.font(.bitchatSystem(size: 10))
.foregroundColor(rowColor)
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: person))
.accessibilityAddTraits(person.isMe ? [] : .isButton)
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !person.isMe {
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
if person.isBlocked {
peerListModel.unblockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
} else {
peerListModel.blockGeohashUser(
pubkeyHexLowercased: person.id,
displayName: person.displayName
)
}
}
}
}
}
}
// Seed and update order outside result builder
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
}
}
}
/// One spoken sentence per row: name, presence type, and block state.
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
var parts: [String] = [person.displayName]
if person.isMe { parts.append(Strings.youState) }
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
if person.isBlocked { parts.append(Strings.blockedState) }
return parts.joined(separator: ", ")
}
}
+38 -20
View File
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
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")
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Group {
switch locationChannelsModel.permissionState {
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
}
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
SheetCloseButton { isPresented = false }
}
private var channelList: some View {
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
}
.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))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) {
Text(verbatim: "#")
.bitchatFont(size: 14)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
.background(palette.secondary.opacity(0.12))
.cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count)
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
}
.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 {
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
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) {
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
Text(parts.base)
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
.foregroundColor(titleColor ?? palette.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
.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]"
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
.foregroundColor(palette.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.background(palette.secondary.opacity(0.12))
.cornerRadius(8)
}
+7 -14
View File
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
}
private var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.closeAccessibility)
SheetCloseButton { dismiss() }
}
private var headerSection: some View {
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
}
.padding(.horizontal, 16)
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
if !ts.isEmpty {
Text(ts)
.bitchatFont(size: 11)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
Spacer()
}
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
Spacer()
}
.padding(.vertical, 8)
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.foregroundColor(palette.secondary)
}
.padding(.vertical, 6)
}
+136 -28
View File
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
#endif
struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL
private let revealProgress: Double?
private let isSending: Bool
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false
@State private var showDeleteConfirmation = false
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init(
url: URL,
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
.overlay(
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.white.opacity(0.85))
VStack(spacing: 6) {
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
Text(verbatim: Strings.tapToReveal)
// Themed: monospaced under matrix,
// system under liquid glass.
.bitchatFont(size: 12, weight: .medium)
}
.foregroundColor(.white.opacity(0.85))
)
}
}
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2))
.fill(palette.secondary.opacity(0.2))
.frame(height: 200)
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
.overlay {
if loadFailed {
Image(systemName: "photo")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
}
if let onCancel = onCancel, isSending {
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.onAppear {
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
loadImage()
}
.gesture(mainGesture)
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async {
self.platformImage = image
self.aspectRatio = ratio
.contextMenu {
if isSending {
cancelSendAction
} else {
imageActions
}
}
.confirmationDialog(
Strings.deleteConfirmTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
}
}
}
private var mainGesture: some Gesture {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
@ViewBuilder
private var cancelSendAction: some View {
if let onCancel {
Button(Strings.cancelSend, action: onCancel)
}
}
@ViewBuilder
private var imageActions: some View {
// Open/reveal/hide would act on a file that failed to load, so only
// offer delete (when available) to let users clean up the attachment.
if !loadFailed {
if isBlurred {
Button(Strings.reveal) {
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
}
} else {
Button(Strings.open) { onOpen?() }
Button(Strings.hide) {
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
}
}
}
if onDelete != nil {
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
}
}
private var accessibilityLabelText: String {
if isSending { return Strings.sendingImage }
if loadFailed { return Strings.unavailableImage }
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
}
private var accessibilityHintText: String {
if isSending || loadFailed { return "" }
return isBlurred ? Strings.revealHint : Strings.openHint
}
private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
let image = UIImage(contentsOfFile: url.path)
#else
let image = NSImage(contentsOf: url)
#endif
DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
}
}
}
// Double-tap used to permanently delete the image the most ingrained
// photo gesture on mobile, racing the reveal tap, with no confirmation
// and no way to get the file back. Delete now lives in the context menu
// behind a confirmation; taps only reveal and open.
private var mainGesture: some Gesture {
let singleTap = TapGesture().onEnded {
guard !isSending else { return }
guard !isSending, !loadFailed else { return }
if isBlurred {
withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
}
}
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending else { return }
guard !isSending, !loadFailed else { return }
let horizontal = value.translation.width
let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
}
}
}
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
return singleTap.simultaneously(with: swipe)
}
}
+81 -32
View File
@@ -11,6 +11,7 @@ import BitFoundation
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL?
@@ -35,46 +37,93 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Baseline alignment (via the header text inside the VStack) keeps the
// lock on the header line; a fixed top padding left its solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
}
.padding(.vertical, 4)
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
}
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
+13 -2
View File
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
// Palette-based and slightly translucent so the card doesn't sit as
// an opaque white/black box over the glass gradient.
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
}
private var borderColor: Color {
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
WaveformView(
samples: samples,
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
Text(playbackLabel)
.bitchatFont(size: 13)
.foregroundColor(Color.secondary)
.foregroundColor(palette.secondary)
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
.foregroundColor(.white)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
}
}
.padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue
} else {
color = Color.gray.opacity(0.35)
color = palette.secondary.opacity(0.35)
}
context.fill(Path(rect), with: .color(color))
}
+100 -1
View File
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
/// Optional so existing call sites (and previews/tests) keep compiling;
/// when absent the block/unblock context-menu entry is hidden.
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
}
var body: some View {
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.bitchatSystem(size: 10))
.foregroundColor(.purple)
.help(Strings.nostr)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
.help(Strings.offline)
}
let (base, suffix) = peer.displayName.splitSuffix()
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
Text(base)
.bitchatFont(size: 14)
.foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
// Optical centering: lock glyph ink is
// bottom-heavy, so geometric centering
// reads low next to the name.
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
} else {
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
}
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
// Widen the tap target beyond the bare glyph;
// height stays row-bound so neighboring rows
// keep their own taps.
.frame(width: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
// count:2 must attach before count:1 or the single tap
// shadows it (same ordering the header logo relies on).
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.contextMenu {
if !isMe {
Button(Strings.directMessage) {
onTapPeer(peer.peerID)
}
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
if peer.isBlocked {
Button(Strings.unblock) {
onToggleBlock(peer)
}
} else {
Button(Strings.block, role: .destructive) {
onToggleBlock(peer)
}
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: peer))
.accessibilityAddTraits(isMe ? [] : .isButton)
.accessibilityHint(isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !isMe {
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
onToggleBlock(peer)
}
}
}
}
}
}
// Seed and update order outside result builder
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
}
}
}
/// One spoken sentence per row: name, how they're reachable, and any
/// state badges the visual row is icon soup for VoiceOver otherwise.
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
var parts: [String] = [peer.displayName]
if !peer.isMe {
if peer.isConnected {
parts.append(Strings.connected)
} else if peer.isReachable {
parts.append(Strings.reachable)
} else if peer.isMutualFavorite {
parts.append(Strings.nostr)
} else {
parts.append(Strings.offline)
}
}
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
return parts.joined(separator: ", ")
}
}
+241 -24
View File
@@ -36,8 +36,17 @@ struct MessageListView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var unseenCount = 0
@State private var lastSeenMessageCount = 0
/// Context key the unseen counters were baselined against. Channel
/// switches swap the timeline wholesale, so a count delta is only a
/// "new messages" signal while the context is unchanged.
@State private var unseenBaselineKey = ""
@ThemedPalette private var palette
var body: some View {
let currentWindowCount: Int = {
@@ -65,6 +74,9 @@ struct MessageListView: View {
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
@@ -72,6 +84,7 @@ struct MessageListView: View {
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
unseenCount = 0
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
@@ -89,13 +102,32 @@ struct MessageListView: View {
}
}
.contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
if showsUserActions {
// Mention and DM are redundant inside a 1:1 conversation:
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
}
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
}
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
@@ -105,6 +137,16 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string)
#endif
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
@@ -113,9 +155,24 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
conversationUIModel.clearCurrentConversation()
showClearConfirmation = true
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
scrollToBottom(on: proxy)
@@ -139,9 +196,7 @@ struct MessageListView: View {
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
insertMention(sender)
}
}
@@ -208,6 +263,142 @@ struct MessageListView: View {
}
private extension MessageListView {
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
var publicEmptyState: some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
func emptyStateLine(_ text: String) -> 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(palette.secondary.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
/// Messages the unseen counters may book as "new": rows that render as
/// human messages. System lines render as narration and whitespace-only
/// content never renders at all, so neither belongs in the pill count.
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
}
/// Updates the unseen-count baseline for the current context and returns
/// how many messages were appended since the last observation. A context
/// change (timeline swapped wholesale) re-baselines and reports zero, so
/// cross-channel count differences are never booked as "new" messages.
func rebaselinedAppendedCount(newCount: Int) -> Int {
let key = currentContextKey
if unseenBaselineKey != key {
unseenBaselineKey = key
unseenCount = 0
lastSeenMessageCount = newCount
return 0
}
let appended = max(0, newCount - lastSeenMessageCount)
lastSeenMessageCount = newCount
return appended
}
/// A failed private text message of our own can be resent through the
/// normal send path (the context menu removes the failed original and
/// re-submits its content).
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
else { return false }
return true
}
/// Appends an @mention to the composer draft (never overwrites what the
/// user has already typed) and focuses the input field.
func insertMention(_ sender: String) {
let mention = "@\(sender) "
if messageText.isEmpty {
messageText = mention
} else if messageText.hasSuffix(" ") {
messageText += mention
} else {
messageText += " " + mention
}
isTextFieldFocused.wrappedValue = true
}
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
/// unseenCount state the view already tracks, and jumps to the newest
/// message via the existing scrollToBottom helper.
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(on: proxy)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down")
.font(.bitchatSystem(size: 11, weight: .semibold))
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
)
.bitchatFont(size: 12, weight: .medium)
}
}
.foregroundColor(palette.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedOverlayPanel()
.padding(.trailing, 12)
.padding(.bottom, 10)
.accessibilityLabel(jumpToLatestAccessibilityLabel)
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
return "\(base), \(count)"
}
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
@@ -294,6 +485,9 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
@@ -316,15 +510,23 @@ private extension MessageListView {
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages
guard privatePeer == nil, let lastMsg = messages.last else { return }
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return
}
// If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -352,18 +554,23 @@ private extension MessageListView {
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer,
let lastMsg = privateInboxModel.messages(for: peerID).last else {
guard let peerID = privatePeer else { return }
let messages = privateInboxModel.messages(for: peerID)
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
return
}
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else {
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -391,17 +598,27 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
// Invalidate the unseen baseline: the timeline is about to swap (or
// already has the ordering of this onChange vs the count onChange
// is not guaranteed), so the next count observation re-baselines
// instead of booking the cross-channel difference as "new".
unseenCount = 0
unseenBaselineKey = ""
// Entering any public channel shows its latest messages: a channel
// switch swaps the timeline wholesale, so the prior scroll offset is
// meaningless. Landing at the bottom keeps isAtBottom honest (no
// stale jump-to-latest pill) and matches standard chat behavior.
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey: String
switch channel {
case .mesh:
break
contextKey = "mesh"
case .location(let ch):
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
contextKey = "geo:\(ch.geohash)"
}
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
@@ -426,6 +643,6 @@ private extension ChannelID {
}
}
//#Preview {
// #Preview {
// MessageListView()
//}
// }
+13 -12
View File
@@ -11,7 +11,10 @@ import AppKit
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) }
@ThemedPalette private var palette
// Palette-tinted so the box follows the theme (green under matrix)
// instead of a fixed gray band over the glass gradient.
private var boxColor: Color { palette.secondary.opacity(0.1) }
private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -50,6 +53,7 @@ struct MyQRView: View {
struct QRCodeImage: View {
let data: String
let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text(Strings.unavailable)
.bitchatFont(size: 12)
.foregroundColor(.gray)
.foregroundColor(palette.secondary)
)
}
}
@@ -108,6 +112,7 @@ struct ImageWrapper: View {
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@@ -153,7 +158,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
.border(palette.secondary.opacity(0.4))
Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) }
private var boxColor: Color { palette.secondary.opacity(0.1) }
var body: some View {
VStack(spacing: 0) {
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
Button(action: {
SheetCloseButton {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
.foregroundColor(accentColor)
}
.padding(.horizontal, 16)
.padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me",
PeerID(str: "3333333333333333"): "me"
]
// Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me",
"ffffeeeeddddcccc2222": "me"
]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2),
makePrivateMessage(id: "m2", timestamp: t2)
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
]
coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
]
coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
),
)
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages,
noiseKeyWithMessages
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
]
coordinator.didUpdatePeerList([currentPeer])
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me")
]
coordinator.handleDelivered(
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
]
coordinator.didDisconnectFromPeer(peerID)
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
// LocationChannelManager is a process-wide singleton: a suite
// running in parallel (e.g. CommandProcessorTests) can flip the
// selected channel mid-test, which reroutes or drops the event
// permanently no amount of waiting recovers it. Re-assert the
// channel and redeliver on each poll: every channel switch clears
// the processed-event set and the store dedups by message ID, so
// redelivery is idempotent and interference heals on the next
// poll while a genuine failure still times out.
if LocationChannelManager.shared.selectedChannel != channel {
LocationChannelManager.shared.select(channel)
}
if viewModel.activeChannel == channel {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
#expect(didAppend)
}
@@ -1000,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
// Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1020,7 +1039,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 5.0)
}, timeout: TestConstants.longTimeout)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
@@ -1036,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1057,7 +1076,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 5.0)
}, timeout: TestConstants.longTimeout)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
+53
View File
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty)
}
}
@Test @MainActor
func handleCommand_outputRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/help")
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
}
@Test @MainActor
func handleCommand_errorRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/bogus")
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
}
@Test @MainActor
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.handleCommand("/bogus")
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
}
@Test @MainActor
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
#expect(viewModel.selectedPrivateChatPeer == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
}
}
// MARK: - Composer Tests
@@ -174,7 +174,7 @@ struct IntegrationTests {
}
// Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)!
let plaintext = Data("Encrypted message".utf8)
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { message in
helper.nodes["David"]!.messageDeliveryHandler = { _ in
completion()
}
@@ -288,7 +288,7 @@ struct IntegrationTests {
}
do {
let plaintext = "After restart success".data(using: .utf8)!
let plaintext = Data("After restart success".utf8)
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(),
Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32),
Data(repeating: 0xFF, count: 32)
]
for invalidKey in invalidKeys {
+27 -28
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Hello, Bob!".data(using: .utf8)!
let plaintext = Data("Hello, Bob!".utf8)
// Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceMessage = Data("Hello from Alice".utf8)
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage)
// Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobMessage = Data("Hello from Bob".utf8)
let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
}
@Test func encryptionBeforeHandshake() {
let plaintext = "test".data(using: .utf8)!
let plaintext = Data("test".utf8)
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager
let plaintext = "Test message".data(using: .utf8)!
let plaintext = Data("Test message".utf8)
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)!
let plaintext = Data("Secret message".utf8)
var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)!
let plaintext = Data("Test message".utf8)
let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
_ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)!
let testMessage = Data("After restart".utf8)
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces
for i in 0..<5 {
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
_ = try bobSession.decrypt(msg)
}
// Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 {
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
_ = try aliceSession.encrypt(Data("Lost message \(i)".utf8))
}
// With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
#expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage)
}
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
{ completion in
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount {
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let plaintext = Data("Concurrent message \(i)".utf8)
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted
}
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
return
}
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)!
let expected = Data("Concurrent message \(i)".utf8)
#expect(decrypted == expected)
completion()
} catch {
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)!
let testMessage = Data("Session works".utf8)
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage)
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)!
let testMessage2 = Data("New session works".utf8)
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2)
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
// Exchange messages normally
for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
_ = try bobManager.decrypt(msg, from: bobPeerID)
}
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
}
// With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt(
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
Data("This now succeeds".utf8), for: alicePeerID)
#expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = "Resynced".data(using: .utf8)!
let testResynced = Data("Resynced".utf8)
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced)
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
let plaintext1 = Data("Hello from Alice after secureClear!".utf8)
let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
let plaintext2 = Data("Hello from Bob after secureClear!".utf8)
let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct
for i in 1...10 {
let msg = "Message \(i) from Alice".data(using: .utf8)!
let msg = Data("Message \(i) from Alice".utf8)
let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher)
#expect(dec == msg)
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
]
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?",
"@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat",
"teleport check, who's local?",
"teleport check, who's local?"
]
return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent(
+10 -5
View File
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests {
@Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
#expect(CommandInfo.message.id == "dm")
#expect(CommandInfo.message.alias == "/dm")
// Aliases must match what CommandProcessor actually accepts
// the suggestion panel is the only command-discovery surface.
#expect(CommandInfo.message.id == "msg")
#expect(CommandInfo.message.alias == "/msg")
#expect(CommandInfo.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
// Favorites are rejected by the processor in geohash contexts, so
// they are suggested only in mesh.
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
}
@Test
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID))
}
@Test @MainActor
func setBlocked_persistsByFingerprintAndToggles() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000EE")
let fingerprint = "fp-target"
transport.peerFingerprints[peerID] = fingerprint
// Blocking resolves and persists by the peer's fingerprint.
let resolved = service.setBlocked(peerID, blocked: true)
#expect(resolved == fingerprint)
#expect(identity.isBlocked(fingerprint: fingerprint))
#expect(service.isBlocked(peerID))
// Unblocking clears it against the same identity.
let unresolved = service.setBlocked(peerID, blocked: false)
#expect(unresolved == fingerprint)
#expect(!identity.isBlocked(fingerprint: fingerprint))
#expect(!service.isBlocked(peerID))
}
@Test @MainActor
func setBlocked_unknownIdentityReturnsNil() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
// No fingerprint resolvable for this peer (offline & unknown).
let peerID = PeerID(str: "00000000000000FF")
#expect(service.setBlocked(peerID, blocked: true) == nil)
#expect(!service.isBlocked(peerID))
}
}
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,6 +12,11 @@ import Foundation
struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.0
/// For positive waits on work that hops through `Task.detached` or
/// background queues: those contend with every parallel test worker for
/// the global executor, so a loaded CI runner can exceed
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
/// so passing runs never pay the longer timeout.
static let longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!,
payload: Data = Data("test payload".utf8),
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: senderID.id.data(using: .utf8)!,
senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
+13 -3
View File
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
@Test
func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL()
// Probed directly below. Deliberately a separate file from `audioURL`:
// `WaveformCache.shared` is process-wide and the mounted
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
// width concurrently, so asserting an exact bin count for that URL
// races with the view's own cache write.
let waveformProbeURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL()
defer {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
}
let waveformView = WaveformView(
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
continuation.resume(returning: values)
}
}
playback.loadDuration()
try? await Task.sleep(nanoseconds: 250_000_000)
// loadDuration hops through a background queue and back to main; poll
// instead of a fixed sleep so a loaded runner can't outlast the wait.
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
playback.seek(to: 1.25)
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
#expect(playback.duration > 0)
#expect(playback.progress == 0)
}
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests {
@Test func sealAndOpenRoundtrip() throws {
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealAndOpenWithAAD() throws {
let plaintext = "Secret message".data(using: .utf8)!
let plaintext = Data("Secret message".utf8)
let key = Data(repeating: 0xAB, count: 32)
let nonce = Data(repeating: 0xCD, count: 24)
let aad = "additional authenticated data".data(using: .utf8)!
let aad = Data("additional authenticated data".utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
let decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
let plaintext = "Same plaintext".data(using: .utf8)!
let plaintext = Data("Same plaintext".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyKey() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyNonce() {
let plaintext = "Test".data(using: .utf8)!
let plaintext = Data("Test".utf8)
let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithWrongKey() throws {
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithTamperedCiphertext() throws {
let plaintext = "Secret".data(using: .utf8)!
let plaintext = Data("Secret".utf8)
let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
+7 -7
View File
@@ -5,16 +5,16 @@ let package = Package(
name: "Tor", // Keep name "Tor" for drop-in compatibility
platforms: [
.iOS(.v16),
.macOS(.v13),
.macOS(.v13)
],
products: [
.library(
name: "Tor",
targets: ["Tor"]
),
)
],
dependencies: [
.package(path: "../BitLogger"),
.package(path: "../BitLogger")
],
targets: [
// Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor",
dependencies: [
"arti",
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "BitLogger", package: "BitLogger")
],
path: "Sources",
exclude: ["C"],
sources: [
"TorManager.swift",
"TorURLSession.swift",
"TorNotifications.swift",
"TorNotifications.swift"
],
linkerSettings: [
.linkedLibrary("resolv"),
.linkedLibrary("z"),
.linkedLibrary("sqlite3"),
.linkedLibrary("sqlite3")
]
),
// Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget(
name: "arti",
path: "Frameworks/arti.xcframework"
),
)
]
)
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target(
name: "BitFoundation",
dependencies: [
.product(name: "BitLogger", package: "BitLogger"),
.product(name: "BitLogger", package: "BitLogger")
],
path: "Sources"
),
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
// Verify recipient
#expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
// TODO: Check if this is intended that the decoding only gets the first 8
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
}
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
@Test("Create a large, compressible payload above current threshold (2048B)")
func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = repeatedString.data(using: .utf8)!
let largePayload = Data(repeatedString.utf8)
let packet = TestHelpers.createTestPacket(payload: largePayload)
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
@Test("Small payloads should not be compressed")
func smallPayloadNoCompression() throws {
let smallPayload = "Hi".data(using: .utf8)!
let smallPayload = Data("Hi".utf8)
let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
var encodedSizes = Set<Int>()
for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8))
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!,
payload: Data = Data("test payload".utf8),
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: senderID.id.data(using: .utf8)!,
senderID: Data(senderID.id.utf8),
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
+86 -13
View File
@@ -11,16 +11,34 @@
# never runner variance. Raise floors deliberately after intentional
# improvements; never tune them to chase noise.
#
# Retry-on-noise: even generous floors can be dipped under by a saturated
# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub
# macOS runner). When a benchmark lands below its floor, the gate re-runs the
# benchmark suite — appending to the same PERF log — and keeps each
# benchmark's BEST observed value across attempts. Runner noise clears on a
# retry; a real algorithmic regression stays below floor on every attempt and
# still fails. Floors themselves are never lowered by this mechanism.
#
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
#
# Environment:
# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3)
# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor
# result (default: swift test --quiet
# --filter PerformanceBaselineTests). The
# command runs with BITCHAT_PERF_LOG pointed at
# the output file so new PERF lines append.
#
# Skips gracefully (exit 0) when:
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
# - the output contains no PERF lines (e.g. package-only matrix entries).
#
# Fails (exit 1) when:
# - any benchmark reports throughput below its floor, or
# - PERF lines are present but a floored benchmark is missing
# (a silently-dropped benchmark must be an explicit floors-file change).
# Fails when:
# - any benchmark reports throughput below its floor on every attempt
# (exit 1), or
# - PERF lines are present but a floored benchmark is missing — a
# silently-dropped benchmark must be an explicit floors-file change and
# is not retried (exit 3).
set -euo pipefail
@@ -31,6 +49,8 @@ fi
OUTPUT_FILE="$1"
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}"
REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}"
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
@@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
exit 0
fi
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
# Absolute path so re-measurement appends to the same file regardless of the
# working directory the test process runs in.
case "$OUTPUT_FILE" in
/*) ;;
*) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;;
esac
# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs
# regression undecided), 3 = floored benchmark missing (not retryable).
check_floors() {
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
import json
import os
import re
@@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f:
for line in f:
m = pattern.search(line)
if m:
# Keep the last reported value if a benchmark prints twice.
measured[m.group(1)] = (float(m.group(2)), m.group(3))
# Keep the BEST reported value: measurement retries append to the
# same log, and a healthy benchmark only needs to clear its floor
# once — a real regression never does.
name, value, unit = m.group(1), float(m.group(2)), m.group(3)
if name not in measured or value > measured[name][0]:
measured[name] = (value, unit)
failures = []
below_floor = []
missing = []
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
for name in sorted(set(floors) | set(measured)):
floor = floors.get(name)
if name not in measured:
failures.append(
missing.append(
f" MISSING {name}: floored benchmark reported no PERF line "
f"(removed/renamed? update perf-floors.json in the same change)")
continue
@@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)):
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
print(line)
if value < floor:
failures.append(
below_floor.append(
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
f"({value / floor * 100:.0f}% of floor)")
if failures:
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
print("\n".join(failures))
if missing:
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:")
print("\n".join(missing + below_floor))
sys.exit(3)
if below_floor:
print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:")
print("\n".join(below_floor))
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
print("algorithmic regression, not runner noise. If the change is intentional,")
print("update bitchatTests/Performance/perf-floors.json deliberately.")
@@ -106,3 +146,36 @@ if failures:
print("perf-floors: all benchmarks at or above their floors.")
PYEOF
}
attempt=1
while true; do
gate_status=0
check_floors || gate_status=$?
case "$gate_status" in
0)
exit 0
;;
1)
# Below floor: retry to separate runner noise from regression.
;;
*)
# Missing benchmark or parse/setup error: re-measuring can't help.
exit "$gate_status"
;;
esac
if (( attempt >= MAX_ATTEMPTS )); then
echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2
exit 1
fi
attempt=$((attempt + 1))
echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression."
# Word splitting of REMEASURE_CMD is deliberate: it is a command line.
if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then
echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2
exit 1
fi
done