- Set overrideUserInterfaceStyle = .dark on UIImagePickerController
- Changes white bars to black (much better looking)
- Camera controls and photo library also appear in dark mode
- Consistent dark appearance regardless of system settings
- Changed back to standard .fullScreen presentation
- Removed overFullScreen which was causing frame dimension errors
- Let iOS handle safe areas automatically (white bars are intentional)
- Reduces gesture gate timeout warnings
Note: White bars on notched devices are iOS default behavior for
UIImagePickerController. This respects safe areas for status bar
and home indicator. True edge-to-edge would require custom AVFoundation
camera implementation.
UX improvements:
- Tap camera icon → Photo library (common use case)
- Long press camera icon (0.3s) → Direct camera (quick photos)
- Removed action sheet entirely for cleaner flow
- Power users can long-press for instant camera access
This is more discoverable and eliminates an extra step in the UI.
- Changed modalPresentationStyle from .fullScreen to .overFullScreen
- This should eliminate white bars at top/bottom on notched devices
- Explicitly set showsCameraControls and cameraOverlayView for camera mode
- Change to fullScreenCover for immersive camera experience
- Add action sheet with 'Take Photo' and 'Choose from Library' options
- Renamed ImagePickerView to support both camera and library sources
- Both options open full-screen for better UX
- Updated accessibility label to 'Add photo' (more accurate)
- Change paperclip icon to camera icon for clearer affordance
- Open camera directly on tap (no confirmation dialog)
- Add CameraPickerView wrapper for UIImagePickerController
- Remove PhotosPicker in favor of direct camera access
- Images still processed through ImageUtils with EXIF stripping
- Accessibility: Added 'Take photo' label
- Fix critical EXIF privacy issue in iOS image processing
- Both iOS and macOS now use CGImageDestination for metadata stripping
- Shared encodeJPEG function ensures no GPS, camera, or metadata leaks
- Remove file transfer functionality to simplify PR scope
- Deleted FileAttachmentView
- Removed sendFileAttachment from ChatViewModel
- Removed file picker UI from ContentView
- Simplified attachment dialog to image only (iOS) or voice only
- Keep focused media features:
- Voice recording and playback
- Image sending with progressive reveal
- Binary protocol for media transfer
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.
Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header
Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity
Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Simplifies geonotes architecture by eliminating all background subscriptions:
- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed
Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes
The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
PhotosPickerItem has SDK availability issues on macOS in CI.
Change PhotosPicker from canImport(PhotosUI) to os(iOS) only.
macOS users can still import images via file importer (.fileImporter).
This is actually cleaner as macOS file picker is more familiar to users.
Fixes CI build failures.
PhotosPickerItem requires iOS 16+ / macOS 13+ but canImport(PhotosUI)
succeeds on older macOS versions. Add compiler version check to ensure
PhotosPicker code only compiles when actually available.
This fixes CI build failures on older macOS environments.
- Fix PhotosUI import order (must be after platform imports)
- Fix Data.WritingOptions.atomic reference
- Add identity derivation caching to NostrIdentityBridge
- Fix all remaining PeerID type conversions in ChatViewModel
- Fix ContentView body structure to use main's VStack layout
- Fix PaymentChipView API usage (now uses PaymentType enum)
Build and tests now passing.
- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files)
- Add caching to NostrIdentityBridge.deriveIdentity() for performance
- Remove duplicate NotificationStreamAssembler from BLEService.swift
- Remove duplicate function declarations in BLEService.swift
- Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift
- Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping)
- Update ContentView body to use main's simple VStack structure
- Fix NostrIdentityBridge instance method calls
- Remove privateChatView (replaced with sheet-based UI in main)
Build and tests passing (137/139 tests pass).
Fragment Reassembly Race Condition (CRITICAL):
- Wrap all incomingFragments/fragmentMetadata access in collectionsQueue.sync
- Prevents concurrent modification crashes from multi-threaded access
- Minimizes lock contention by doing heavy work (reassembly/decode) outside locks
- Add upper bound check: reject fragments with total > 10,000 (DoS prevention)
- Add cumulative size validation before storing fragments (memory DoS prevention)
File Cleanup Path Traversal (CRITICAL):
- Use NSString.lastPathComponent to extract filename safely
- Prevents directory traversal attacks via malicious filenames
- Add path prefix validation before file deletion
- Now checks both incoming and outgoing directories (fixes disk leak)
Additional Protections:
- Fragment assemblies now limited by both count (128) and cumulative bytes (1MB)
- Explicit checks for "." and ".." filenames in cleanup
- Defense-in-depth: multiple validation layers
- Reduce sample rate from 44.1 kHz to 16 kHz (telephony standard)
- Lower bitrate from 32 kbps to 20 kbps
- Results in ~37% file size reduction (~150 KB/min vs 240 KB/min)
- Increases max voice note length from 4.4 to 7 minutes over 1 MiB BLE limit
- Maintains excellent voice quality using native AAC-LC codec
- Mark non-localizable strings with Text(verbatim:) to prevent extraction
- Update UI strings to lowercase per style guide (open, save, close, recording)
- Add complete translations for all 29 languages (194/194 strings at 100%)
- Remove empty/duplicate entries (@, bitchat/, Open, Recording %@)
- Add proper localization comments for all user-facing strings
Fix: /clear command and panicClearAllData() now properly delete media files
1. /clear (triple-tap on chat):
- Deletes outgoing media (voice notes, images, files)
- Conservative: only our sent media, preserves received media
- Runs in background to avoid UI freeze
2. panicClearAllData() (triple-tap on bitchat/ header):
- Deletes ALL media files (incoming + outgoing)
- Removes entire files directory and recreates structure
- Ensures complete data wipe for emergency scenarios
Both operations run async on .utility queue to prevent blocking UI.
Aggressive performance optimization to prevent UI freezes:
Problem: Even with async loading, creating 10+ VoiceNotePlaybackController
instances simultaneously (when scrolling past multiple voice notes) spawned
20+ concurrent background tasks, potentially starving main thread.
Solution - Ultra-lazy loading:
1. VoiceNotePlaybackController.init() now does ZERO work
- No duration loading
- No player creation
- Instant initialization
2. Duration loaded on-demand via public loadDuration() method
- Called from VoiceNoteView.onAppear after 150ms delay
- Reduced priority: .utility instead of .userInitiated
- Guard prevents duplicate loading
3. Waveform loading also deferred 150ms
- Gives UI time to settle after message appears
- Prevents task storms when multiple voice notes appear
This spreads the work over time instead of all at once.
Additional optimization for location channels (voice notes are mesh-only,
but this helps with text message rendering in geohash channels):
- Add cachedGeohashIdentity to avoid deriveIdentity calls during rendering
- Check cache before falling back to crypto derivation
- Reduces main thread crypto work in location channels
Critical performance fix:
Problem: formatMessageHeader() called deriveIdentity(forGeohash:) during
every SwiftUI render for every media message. Each call performed:
- Keychain I/O (getOrCreateDeviceSeed)
- HMAC-SHA256 computation
- Up to 10 secp256k1 key validations (elliptic curve crypto)
With multiple media messages, this resulted in 100s of milliseconds of
blocking crypto on main thread per render cycle.
Solution: Add thread-safe cache for derived identities
- Check cache before expensive crypto operations
- NSLock protects concurrent access
- Identity is deterministic per geohash, so caching is safe
This eliminates crypto from the hot rendering path.
Critical performance fix for UI freezes when receiving media:
Problem: mediaAttachment(for:) was called during every SwiftUI render,
performing synchronous disk I/O on main thread:
- FileManager.fileExists() called 2-6x per message (checking subdirs)
- applicationFilesDirectory() creating directories on every call
- With multiple media messages, this meant 20-100+ disk ops per render
Solution:
1. Remove fileExists checks - construct URLs directly
- Files are validated during playback/display (fail gracefully if missing)
- Sender determines subdirectory (outgoing vs incoming)
2. Cache applicationFilesDirectory() result
- Static cache prevents repeated FileManager.url() calls
- Directory created only once
3. Remove redundant playback.replaceURL() in VoiceNoteView.onAppear
- Controller already initialized with correct URL
This eliminates ALL disk I/O from the view rendering hot path.
Fixes:
1. Post-playback freeze: audioPlayerDidFinishPlaying now dispatches to main
thread before updating @Published properties (Swift concurrency violation)
2. Unbounded waveform cache: Implement LRU eviction with 20-entry limit
- Track last access time for each cached waveform
- Evict oldest entry when cache is full
- Prevents unlimited memory growth as voice notes accumulate
3. Audio buffer memory leaks: Wrap computeWaveform in autoreleasepool
- AVAudioPCMBuffer allocations are autoreleased
- Pool ensures buffers are freed promptly
4. Image processing memory: Add autoreleasepool around compression loops
- Each jpegData() call creates temporary objects
- Inner pool per iteration prevents memory spikes during quality search
Memory should now remain stable during extended use.
Problem: AVAudioPlayer initialization in VoiceNotePlaybackController.init()
was running synchronously on main thread during view creation, blocking
UI for 50-200ms per voice note.
Solution:
- Remove eager preparePlayer() call from init
- Load duration asynchronously on background queue
- Player is only prepared when playback is actually requested via ensurePlayerReady()
This prevents UI freezes when voice notes appear in the chat.
* Allow closing people sheet from X and swipe
* Swipe right to return from DM to people list
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Align DM sheet toolbar with people list
* Gate stale gossip announcements
* Remove stale peer messages during gossip cleanup
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Restructured ContentView layout to prevent sidebar from covering input box
and removed gesture conflicts that caused jitter during slow drags.
Changes:
- Moved sidebar overlay to only cover messages area, not input box
- Input box now always accessible below sidebar (not covered by overlay)
- Removed blocking drag gesture from mainChatView
- Changed sidebar gesture from simultaneousGesture to gesture for priority
- Removed animation-disabling transactions that amplified touch noise
- Removed 2pt threshold checks that caused visible jumps
Result: Send button taps immediately, sidebar slides smoothly without jitter.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Location notes: fix performance and UI issues
Performance fixes:
- Add Set-based duplicate detection (O(1) vs O(n) lookup)
- Eliminates lag when receiving 200+ notes during EOSE
Correctness fixes:
- Fix optimistic echo timestamp to match signed event timestamp
- Add echo IDs to noteIDs set for consistency
UI improvements:
- Remove duplicate "loading recent notes" text in header
- Simplify toolbar icon color logic for immediate green indication
- Icon now reliably turns green when notes exist in geohash
Tests: All 3 LocationNotes tests passing
* Location notes: add remaining robustness fixes
Additional improvements:
- Align counter/manager limits to 200 (prevents showing count higher than displayable)
- Set loading state before clearing notes to eliminate UI flicker on geohash change
- Add geohash validation for building-level precision (8 valid base32 chars)
- Add defensive 500-note memory cap with automatic trimming
- Clear stale notesGeohash state on sheet dismiss
Tests:
- Fix test geohashes to use valid base32 characters
- All 3 LocationNotes tests passing
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
The camera scanner was continuously detecting the same QR code (10-30+ times/second), causing multiple verification notifications to be sent. This happened because:
1. AVCaptureMetadataOutput fires repeatedly while QR is visible
2. Each detection triggered a new verification flow
3. After receiving response, pendingQRVerifications was cleared, allowing duplicate scans
Changes:
- Add deduplication using lastValid state to ignore re-scans of same QR code
- Only set lastValid after successful verification initiation
- Add onSuccess callback to close scanner after successful scan
- Automatically return to "My QR" view after verification starts
- Apply same logic to both iOS camera and macOS manual input paths
This ensures exactly one verification request per scan session.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Problem:
- Ghost peers from yesterday appeared on app restart
- Old messages resurfaced after restarting
- Peers running 24+ hours synced stale data to restarting peers
Root causes:
1. GossipSyncManager stored packets indefinitely (no time limit)
2. handleAnnounce/handleMessage had no timestamp validation
3. Relayed announces from other peers could be arbitrarily old
Solution (defense in depth):
- Added 15-minute age window to GossipSyncManager config
- Gossip storage rejects expired packets at ingestion
- Gossip sync responses filter out expired packets
- GCS payload building excludes expired packets
- Periodic cleanup removes expired announcements/messages
- handleAnnounce rejects stale announces before processing
- handleMessage rejects stale broadcast messages before processing
This prevents ghost peers from appearing on restart and ensures
only recent mesh state is synchronized between peers.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
* Fix test suite peer ID collisions
Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.
- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs
This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.
* Add Bluetooth permission & state alerts on launch and foreground
Wire up existing Bluetooth alert infrastructure to show notifications
when Bluetooth is off, unauthorized, or unsupported.
Changes:
- Add didUpdateBluetoothState() to BitchatDelegate protocol
- BLEService now notifies delegate when Bluetooth state changes
- ChatViewModel implements delegate method to show alerts
- Check Bluetooth state on app launch (after 100ms delay)
- Check Bluetooth state when app comes to foreground
- Add getCurrentBluetoothState() method to BLEService
The UI alert already existed but wasn't wired up. Now users will see
appropriate alerts for:
- Bluetooth turned off
- Bluetooth permission denied
- Bluetooth unsupported on device
Alert includes a button to open Settings on iOS.
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Use unique peer IDs for each test suite to prevent global registry
collisions when Swift Testing runs suites in parallel.
- PrivateChatE2ETests: PRIV_* prefix
- PublicChatE2ETests: PUB_* prefix
- Update all peer ID references to use actual instance IDs
This fixes the race condition where simplePublicMessage() was receiving
duplicate deliveries due to registry contamination.
Co-authored-by: jack <jackjackbits@users.noreply.github.com>