Compare commits

...
49 Commits
Author SHA1 Message Date
jack e6bd472575 perf(chat): batching, spam rate-limits, near-dup LRU, adaptive flush, faster trims, regex/detector reuse, conditional animations, late-insert, current-mode prewarm, Swift 6-safe timer/closures 2025-08-24 11:11:22 +02:00
jack e3866c9da6 perf(chat): conditional animations via isBatchingPublic; late-arrival binary insert; flush public buffer on channel switch; prewarm formatting on flush 2025-08-24 10:41:45 +02:00
jack 28f59bbd96 perf(chat): batch public inserts, sort batch by ts; add per-sender + per-content token buckets; content-based near-dup suppression; reuse compiled regexes; single token scans per row; disable list animations during batches 2025-08-24 10:31:35 +02:00
0260798712 Fix/UI (#500)
* Remove link previews: link URLs inline\n\n- Delete LinkPreviewView and all references\n- Strip preview usage from ContentView\n- Make detected URLs tappable via .link attribute\n- Add MessageTextHelpers for tokens/length checks\n- Wire helpers into iOS/macOS targets

* Peer list UX: match message colors, use map pin icon, stable ordering\n\n- Color list names and icons with same peer color as messages\n- Default icon mappin; teleported uses face.dashed (iOS)\n- Geohash: move teleported peers to bottom\n- Maintain stable order: append new peers, remove missing\n- Mesh list: replace state icons with colored mappin/person

* Fix peer list build errors: remove ambiguous Set type, avoid guard in ViewBuilder, remove duplicate bindings, and simplify person lookup

* Peer list: remove onChange expressions to avoid type-check issues; rely on ObservedObject updates

* Peer lists: simplify view structure to satisfy SwiftUI type checker (replace Group with VStack, flatten body)

* Peer lists: move ordering mutations to onAppear/onChange, keep body pure; fix result builder errors

* Fix: remove trailing modifiers outside if/else to avoid ViewBuilder type issues

* Color parity: fix seed cache to include color scheme; normalize Nostr seeds; switch to mappin.circle icon

* Peer colors: ensure exact match with message colors\n\n- Mesh list uses same seed logic as messages (getNoiseKeyForShortID fallback)\n- Color cache keyed by theme; normalize nostr seeds\n- Icons: use mappin.and.ellipse (default) and face.dashed for teleported\n- Teleport tag: accept "teleported" in addition to "teleport" and presence events

* Fix geohash color mismatch: populate nostrKeyMapping before building messages in resubscribe; also honor t,teleport tag for teleported state

* Message actions: username tap opens sheet via clickable AttributedString; message tap inserts @mention; rename to 'direct message'

* Cashu UX: suppress 'Show more' when message contains a Cashu token (chips handle rendering)

* Cashu detection: broaden regex (allow '.') + shorter min length; avoid long-text fallback when Cashu present so chips render and no full blob

* Geohash levels: rename region->province, country->region; update labels, precision, UI, manager mapping, tests; add Codable backward-compat for renamed cases

* Fix braces in LocationChannel.swift: close enum before extension to satisfy file-scope declarations

* Geohash links: make #geohash tappable and open that channel (bitchat://geohash/<gh>); handle in ContentView.onOpenURL with validation and selection

* Underline tappable #geohash mentions for clarity

* Geochat: sanitize timeline on channel switch to remove any blank-content entries (prevents blank rows)

* Xcode project: sync sources after UI changes (remove LinkPreview, add helpers, update build refs)

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 02:05:02 +02:00
jack 2758afa126 ux(chat): scroll to bottom on geohash open; add infinite scroll up with stable anchor; track window size per chat 2025-08-22 19:01:29 +02:00
jack 2229a28bb4 perf(chat): reduce rendering cost and improve stability for large chats\n\n- Window last 300 messages (up from 100) while keeping timeline cap at 1337+\n- Remove .textSelection from message rows to avoid expensive layout\n- Limit link previews per message to 2\n- Keep deferred autoscroll and channel-aware IDs for smooth scrolling 2025-08-22 18:56:26 +02:00
dadc896ed8 Fix/geohash blocks (#483)
* fix(geo-block): enable block/unblock for geohash users and enforce blocks\n\n- Add persisted set of blocked Nostr pubkeys in SecureIdentityStateManager\n- Check Nostr blocks for incoming messages (public + geo DMs)\n- Prevent sending geo DMs to blocked users\n- Extend /block and /unblock to resolve geohash display names to pubkeys and act accordingly\n- Improve /block list to show geohash blocks (visible names or #suffix)

* fix(geo-block): enforce blocks on geohash public and DM receive; add block/unblock actions to geohash people list

* fix: mark handlePublicMessage as @MainActor to call isMessageBlocked safely

* ui(block): show blocked indicator (nosign icon) next to blocked peers in mesh and geohash lists

* fix(geo-block): early-drop blocked pubkeys on geohash receive; filter blocked users from participants list

* fix(geo-block): purge existing geohash messages/DMs on block; block directly from chat using Nostr sender ID when available

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 18:44:11 +02:00
b2504a7ff5 Feat/b links (#481)
* feat(cashu): auto-link cashu tokens in chat

- Detect cashuA/cashuB tokens via regex and render as tappable links
- Style like URLs (underline; blue for others, orange for self)
- Add openURL handler for cashu: scheme to delegate to wallet on iOS
- Respect existing heavy-content gating and long-message collapsing

* feat(ln): auto-link Lightning invoices and LNURL + lightning: scheme\n\n- Detect BOLT11 (lnbc/lntb/lnbcrt...), LNURL bech32, and lightning: URIs\n- Render as tappable links with lightning: scheme; consistent styling\n- Handle lightning: in openURL alongside cashu:

* feat(links): replace raw Cashu/Lightning tokens with compact chips (🥜 pay via cashu /  pay via lightning) while keeping them tappable

* style(links): add subtle background highlight to cashu/lightning chips

* ui(links): render Lightning/Cashu as rounded chips under message with padding; remove inline chip text

* ui(links): increase chip padding and corner radius; add extra top padding for chip row

* scroll: auto-scroll to bottom when sending a new message (public + private), regardless of current scroll position

* scroll(geo): when switching geohashes, scroll to top of chat (first visible message)

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 17:20:26 +02:00
7f2cbd6621 chat: fix blanking and gaps when switching geohashes; channel-aware row IDs + deferred autoscroll\n\n- Add channel-aware UI IDs (mesh|id, geo:<gh>|id, dm:<peer>|id) to prevent SwiftUI reuse gaps\n- Defer scrollTo to next runloop for stability; auto-scroll on appear/switch\n- Collapse very long messages with Show more/less; skip heavy parsing for huge content\n- Simplify LazyLinkPreviewView (remove GeometryReader in list) (#480)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 16:38:40 +02:00
jack 60a42ffecb Merge feature/peer-colors: per-peer colors, mention action, teleport persistence + logs, geohash count consistency, scroll gating, hashtag styling, copy message context menu 2025-08-22 13:08:15 +02:00
jack 27e5e2962c Scroll: move bottom detection to View state and pass Binding to messagesView; autoscroll only when bound is true to prevent jumps when user scrolled up 2025-08-22 13:04:56 +02:00
jack 9d5105a8bf Geohash list: gate empty/render using visibleGeohashPeople() so header count and list visibility are consistent 2025-08-22 13:02:34 +02:00
jack d4b779080a Fix header string interpolation: simplify accessibilityLabel to avoid unterminated string; use '\(headerOtherPeersCount) people' 2025-08-22 12:58:08 +02:00
jack 4bfe9ac80d Geohash count: use the same pruned/sorted list for toolbar and peer list (visibleGeohashPeople) to ensure consistency 2025-08-22 12:51:40 +02:00
adffe7dfd6 Feature/peer colors (#476)
* Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors

* Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers

* Fix: re-import CryptoKit for SHA256 usage in ChatViewModel

* Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key

* Fix warning: remove unused peerNicknames in MeshPeerList

* UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input

* UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input

* UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp)

* Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons

* Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior

* Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 12:41:26 +02:00
jack 16fdb7b49e Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport 2025-08-22 12:34:44 +02:00
jack ff5fd3f3fe Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior 2025-08-22 12:29:23 +02:00
jack f8a955214b Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons 2025-08-22 12:24:17 +02:00
jack d5e712e27f UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp) 2025-08-22 12:18:33 +02:00
jack 244c8cdf81 UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input 2025-08-22 12:13:59 +02:00
jack 0d064084aa UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input 2025-08-22 12:12:49 +02:00
jack 7b49268694 Fix warning: remove unused peerNicknames in MeshPeerList 2025-08-22 11:51:17 +02:00
jack 5650e1f2c2 Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key 2025-08-22 11:49:44 +02:00
jack c4b422ad3f Fix: re-import CryptoKit for SHA256 usage in ChatViewModel 2025-08-22 11:47:21 +02:00
jack e82fda1093 Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers 2025-08-22 11:45:49 +02:00
jack 806d451135 Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors 2025-08-22 11:43:44 +02:00
13f8b0c636 Fix/geohash work (#475)
* Scroll UX: auto-scroll only when last item visible; preserve user position when scrolled up for mesh/geohash/DM; reduce blanking after very long messages

* iOS: re-enable keyboard autocomplete and default capitalization for message input

* Styling: stop blue/underline styling for #hashtags; render as normal text color (self=orange, others=green)

* Geo UI: ensure self shows teleported (face.dashed) if either per-session tag or manager flag is true

* Geo teleported: publish UI updates by assigning @Published Set instead of in-place insert; update on tag receipt and channel switch

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 11:38:44 +02:00
2014andGitHub 2d0f9aff0a fixed quote and period (#471)
moved period outside quotes for consistency, as appears elsewhere.
2025-08-22 01:18:46 +02:00
83ee5abb60 Fix: stabilize per-geohash identity seed by storing in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and caching in memory to avoid transient regenerations (#473)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 01:05:44 +02:00
bc27e16899 Fix/visuals (#469)
* Geohash peers: show face.dashed for self when channel selected via teleport; face.smiling otherwise

* Geo presence: broadcast 'teleport' tag on geochat join; track teleported participants and show face.dashed for them in peer list

* Teleport tag: attach to actual geohash chat events (sendMessage, emotes, screenshots) instead of separate presence; remove presence emit

* Fix: show face.dashed for any teleported peer (not just self) in geohash list

* Geo list: show self immediately on channel switch and mark teleported state; clear teleported flags on leaving geochat

* Teleport persistence: recompute teleported based on current location vs selected geohash; add face.dashed icon to Teleport button label

* Styling: use lighter green/orange for #abcd suffix after nicknames in all chats (senders and @mentions)

* Peer lists: render #abcd suffix as lighter green/orange (self orange) in geohash and mesh lists

* Toolbar: move unread icon next to #channel badge and allow dynamic width; Peer lists: increase top spacing before first item

* Toolbar: prevent geohash channel badge from truncating; give it layout priority and fixed width

* Toolbar: make unread envelope independent from channel button (sits left of badge); fix accidental taps opening channel selector

* Toolbar: make unread envelope open most recent unread/private chat directly

* Geohash peer list: render self row fully orange (icon, base, suffix, '(you)')

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-21 11:15:18 +02:00
jack d3d9a22757 Mentions: color @mentions orange only when directed at me; otherwise use normal color (respect geohash suffix) 2025-08-21 03:26:46 +02:00
jack 8a269d4fec UI: grey #abcd suffix in geohash peer list; keep base name (and '(you)') styled normally 2025-08-21 03:02:42 +02:00
jack 33fbca67d6 UI: render self-authored messages in orange (sender and content); keep links/hashtags orange for self 2025-08-21 03:01:14 +02:00
jack c63350a4d3 UI: bold entire message text for self in mesh, DM, and geohash; adjust caching to include self flag 2025-08-21 02:51:14 +02:00
jack 222854c60a UI: rename AppInfoView toolbar button to 'close' for consistency 2025-08-21 02:45:20 +02:00
jack c624611e7d Merge fixes/location-channels into main: resolve LocationChannelsSheet conflict (retain title bolding only) 2025-08-21 02:23:03 +02:00
jack eb0debd52a Fix: route /hug and /slap to active public channel using sendPublicRaw (geohash when selected) 2025-08-21 01:59:47 +02:00
jack 916f535503 Fix: send screenshot notice to active public channel (geohash when selected), not always mesh 2025-08-21 01:52:54 +02:00
jack de39ab6687 UI: stop bolding location subtitle names; only bold the channel label when count > 0 2025-08-21 01:18:44 +02:00
a0a973eb81 Fixes/location channels (#465)
* Remove "street" location channel; add coverage + names; style mesh

- Drop GeohashChannelLevel.street and update mappings/tests\n- Map geohash lengths >=8 to Block in teleport, no Street\n- Show ~distance coverage (mi/km via Locale.measurementSystem) next to each #geohash\n- Add reverse geocoding to display coarse names (country/region/city/neighborhood) per level\n- Style "#mesh" row title with toolbar blue\n- Fix iOS 16 deprecation: use Locale.measurementSystem

* Project: update Xcode project (auto)

* UI: use '~' without trailing space before location name in sheet

* Location sheet: poll for location at regular intervals while open (replace significant-move updates)

* UI: show Bluetooth range next to #bluetooth in location sheet

* UI: remove leading '#' from mesh title in location sheet

* UI: bold location name in sheet when geohash has >0 people; refactor row to render subtitle pieces

* UI: bold mesh/level titles when participant count > 0; factor meshCount()

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-21 01:01:29 +02:00
jack ba1dd100ec UI: bold mesh/level titles when participant count > 0; factor meshCount() 2025-08-21 00:56:29 +02:00
jack 97b1463b30 UI: bold location name in sheet when geohash has >0 people; refactor row to render subtitle pieces 2025-08-21 00:54:14 +02:00
jack 28c87a41c1 UI: remove leading '#' from mesh title in location sheet 2025-08-21 00:51:18 +02:00
jack 68c61476d8 UI: show Bluetooth range next to #bluetooth in location sheet 2025-08-21 00:49:12 +02:00
jack 602e93d1b2 Location sheet: poll for location at regular intervals while open (replace significant-move updates) 2025-08-21 00:46:53 +02:00
jack e7706fc9cb UI: use '~' without trailing space before location name in sheet 2025-08-21 00:44:37 +02:00
jack f30677403f Project: update Xcode project (auto) 2025-08-21 00:41:28 +02:00
jack b4fcea2672 Remove "street" location channel; add coverage + names; style mesh
- Drop GeohashChannelLevel.street and update mappings/tests\n- Map geohash lengths >=8 to Block in teleport, no Street\n- Show ~distance coverage (mi/km via Locale.measurementSystem) next to each #geohash\n- Add reverse geocoding to display coarse names (country/region/city/neighborhood) per level\n- Style "#mesh" row title with toolbar blue\n- Fix iOS 16 deprecation: use Locale.measurementSystem
2025-08-21 00:34:17 +02:00
jack 43166bcd64 iOS: keep mesh alive in background; remove stopServices() on scenePhase .background so incoming messages can still arrive and trigger notifications. 2025-08-20 18:09:57 +02:00
21 changed files with 1853 additions and 814 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ While the Noise handshake cryptographically authenticates a peer's key, it doesn
### 4.2. Favorites and Blocking ### 4.2. Favorites and Blocking
To improve the user experience and provide control over interactions, the protocol supports: To improve the user experience and provide control over interactions, the protocol supports:
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites." This is a local designation that can be used by the application to prioritize notifications or display peers more prominently. * **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer. * **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
--- ---
+12 -12
View File
@@ -60,7 +60,6 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; }; 2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; }; 37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; };
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; }; 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; }; 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
@@ -82,7 +81,6 @@
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; }; 749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; }; 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; }; 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; };
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; }; 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; };
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; }; 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; }; 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; };
@@ -106,6 +104,8 @@
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
@@ -233,9 +233,9 @@
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; }; 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -427,7 +427,7 @@
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
A08E03AA0C63E97C91749AEC /* ContentView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */,
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */, AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -729,7 +729,6 @@
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */, 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
@@ -756,6 +755,7 @@
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */, 049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -782,7 +782,6 @@
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */, B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
@@ -809,6 +808,7 @@
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */, 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -960,7 +960,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -991,7 +991,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1046,7 +1046,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1078,7 +1078,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1167,7 +1167,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -1260,7 +1260,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+2 -2
View File
@@ -44,8 +44,8 @@ struct BitchatApp: App {
.onChange(of: scenePhase) { newPhase in .onChange(of: scenePhase) { newPhase in
switch newPhase { switch newPhase {
case .background: case .background:
// Send leave message when going to background // Keep BLE mesh running in background; BLEService adapts scanning automatically
chatViewModel.meshService.stopServices() break
case .active: case .active:
// Restart services when becoming active // Restart services when becoming active
chatViewModel.meshService.startServices() chatViewModel.meshService.startServices()
+3
View File
@@ -149,6 +149,9 @@ struct IdentityCache: Codable {
// Last interaction timestamps (privacy: optional) // Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:] var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
} }
@@ -336,6 +336,30 @@ class SecureIdentityStateManager {
} }
} }
// MARK: - Geohash (Nostr) Blocking
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
queue.sync {
return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
}
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased()
queue.async(flags: .barrier) {
if isBlocked {
self.cache.blockedNostrPubkeys.insert(key)
} else {
self.cache.blockedNostrPubkeys.remove(key)
}
self.saveIdentityCache()
}
}
func getBlockedNostrPubkeys() -> Set<String> {
queue.sync { cache.blockedNostrPubkeys }
}
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
+14 -3
View File
@@ -5,13 +5,16 @@ import Security
// Keychain helper for secure storage // Keychain helper for secure storage
struct KeychainHelper { struct KeychainHelper {
static func save(key: String, data: Data, service: String) { static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
let query: [String: Any] = [ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data kSecValueData as String: data
] ]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary) SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil) SecItemAdd(query as CFDictionary, nil)
@@ -106,6 +109,8 @@ struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr" private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity" private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed" private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity /// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? { static func getCurrentNostrIdentity() throws -> NostrIdentity? {
@@ -159,14 +164,20 @@ struct NostrIdentityBridge {
/// Returns a stable device seed used to derive unlinkable per-geohash identities. /// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain. /// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data { private static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) { if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing return existing
} }
var seed = Data(count: 32) var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in _ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
} }
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService) // Ensure availability after first unlock to prevent unintended rotation when locked
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed return seed
} }
+5 -1
View File
@@ -103,12 +103,16 @@ struct NostrProtocol {
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil nickname: String? = nil,
teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty { if let nickname = nickname, !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported {
tags.append(["t", "teleport"])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
+1
View File
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
// Default relay list (can be customized) // Default relay list (can be customized)
private static let defaultRelays = [ private static let defaultRelays = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://offchain.pub", "wss://offchain.pub",
"wss://nostr21.com" "wss://nostr21.com"
+4 -4
View File
@@ -410,12 +410,12 @@ class BitchatMessage: Codable {
// Cached formatted text (not included in Codable) // Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:] private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool) -> AttributedString? { func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)"] return _cachedFormattedText["\(isDark)-\(isSelf)"]
} }
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) { func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)"] = text _cachedFormattedText["\(isDark)-\(isSelf)"] = text
} }
// Codable implementation // Codable implementation
+46 -10
View File
@@ -2,33 +2,70 @@ import Foundation
/// Levels of location channels mapped to geohash precisions. /// Levels of location channels mapped to geohash precisions.
enum GeohashChannelLevel: CaseIterable, Codable, Equatable { enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case street
case block case block
case neighborhood case neighborhood
case city case city
case region case province // previously .region
case country case region // previously .country
/// Geohash length used for this level. /// Geohash length used for this level.
var precision: Int { var precision: Int {
switch self { switch self {
case .street: return 8
case .block: return 7 case .block: return 7
case .neighborhood: return 6 case .neighborhood: return 6
case .city: return 5 case .city: return 5
case .region: return 4 case .province: return 4
case .country: return 2 case .region: return 2
} }
} }
var displayName: String { var displayName: String {
switch self { switch self {
case .street: return "Street"
case .block: return "Block" case .block: return "Block"
case .neighborhood: return "Neighborhood" case .neighborhood: return "Neighborhood"
case .city: return "City" case .city: return "City"
case .province: return "Province"
case .region: return "Region" case .region: return "Region"
case .country: return "Country" }
}
}
// Backward-compatible Codable for renamed cases
extension GeohashChannelLevel {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) {
switch raw {
case "block": self = .block
case "neighborhood": self = .neighborhood
case "city": self = .city
case "region": self = .province // old "region" maps to new .province
case "country": self = .region // old "country" maps to new .region
case "province": self = .province
default:
self = .block
}
} else if let precision = try? container.decode(Int.self) {
switch precision {
case 7: self = .block
case 6: self = .neighborhood
case 5: self = .city
case 4: self = .province
case 0...3: self = .region
default: self = .block
}
} else {
self = .block
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city")
case .province: try container.encode("province")
case .region: try container.encode("region")
} }
} }
} }
@@ -68,4 +105,3 @@ enum ChannelID: Equatable, Codable {
} }
} }
} }
+71 -50
View File
@@ -136,8 +136,8 @@ class CommandProcessor {
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID) chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
} }
} else { } else {
// In public chat: send to mesh and also add a local system echo so sender sees it immediately // In public chat: send to active public channel (mesh or geohash)
meshService?.sendMessage(emoteContent, mentions: []) chatViewModel?.sendPublicRaw(emoteContent)
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)" let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
chatViewModel?.addPublicSystemMessage(publicEcho) chatViewModel?.addPublicSystemMessage(publicEcho)
} }
@@ -149,58 +149,75 @@ class CommandProcessor {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty { if targetName.isEmpty {
// List blocked users // List blocked users (mesh) and geohash (Nostr) blocks
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else { let meshBlocked = chatViewModel?.blockedUsers ?? []
return .success(message: "no blocked peers")
}
var blockedNicknames: [String] = [] var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() { if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers { for (peerID, nickname) in peers {
if let fingerprint = meshService?.getFingerprint(for: peerID), if let fingerprint = meshService?.getFingerprint(for: peerID),
blockedUsers.contains(fingerprint) { meshBlocked.contains(fingerprint) {
blockedNicknames.append(nickname) blockedNicknames.append(nickname)
} }
} }
} }
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)" // Geohash blocked names (prefer visible display names; fallback to #suffix)
: blockedNicknames.sorted().joined(separator: ", ") let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
return .success(message: "blocked peers: \(list)") var geoNames: [String] = []
if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
geoNames.append(name)
} else {
let suffix = String(pk.suffix(4))
geoNames.append("anon#\(suffix)")
}
}
}
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
} }
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else { let fingerprint = meshService?.getFingerprint(for: peerID) {
return .error(message: "cannot block \(nickname): not found or unable to verify identity") if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// Block the user (mesh/noise identity)
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked")
}
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats")
} }
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { return .error(message: "cannot block \(nickname): not found or unable to verify identity")
return .success(message: "\(nickname) is already blocked")
}
// Block the user
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
// The peerStateManager and SecureIdentityStateManager handle the blocking state
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
private func handleUnblock(_ args: String) -> CommandResult { private func handleUnblock(_ args: String) -> CommandResult {
@@ -211,19 +228,23 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else { let fingerprint = meshService?.getFingerprint(for: peerID) {
return .error(message: "cannot unblock \(nickname): not found") if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)")
} }
// Try geohash unblock
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
return .success(message: "\(nickname) is not blocked") if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats")
} }
return .error(message: "cannot unblock \(nickname): not found")
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
// The SecureIdentityStateManager handles the unblocking state
return .success(message: "unblocked \(nickname)")
} }
private func handleFavorite(_ args: String, add: Bool) -> CommandResult { private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
+107 -5
View File
@@ -17,14 +17,23 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
private let cl = CLLocationManager() private let cl = CLLocationManager()
private let geocoder = CLGeocoder()
private var lastLocation: CLLocation? private var lastLocation: CLLocation?
private var refreshTimer: Timer? private var refreshTimer: Timer?
private let userDefaultsKey = "locationChannel.selected" private let userDefaultsKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private var isGeocoding: Bool = false
// Published state for UI bindings // Published state for UI bindings
@Published private(set) var permissionState: PermissionState = .notDetermined @Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = [] @Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh @Published private(set) var selectedChannel: ChannelID = .mesh
// True when the current location channel was selected via manual teleport
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// Persisted set of geohashes that were selected via teleport
private var teleportedSet: Set<String> = []
private override init() { private override init() {
super.init() super.init()
@@ -36,6 +45,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel selectedChannel = channel
} }
// Load persisted teleported set
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Initialize teleported flag from persisted state if a location channel is selected
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
@@ -76,15 +94,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin periodic one-shot location refreshes while a selector UI is visible.
func beginLiveRefresh(interval: TimeInterval = 5.0) { func beginLiveRefresh(interval: TimeInterval = 5.0) {
// Prefer continuous updates with a significant distance filter rather than polling
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters // Switch to a lightweight periodic one-shot request (polling) while the sheet is open
cl.distanceFilter = 21 // meters; update on small moves refreshTimer?.invalidate()
cl.startUpdatingLocation() refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
self?.requestOneShotLocation()
}
// Kick off immediately
requestOneShotLocation()
} }
/// Stop periodic refreshes when selector UI is dismissed. /// Stop periodic refreshes when selector UI is dismissed.
func endLiveRefresh() { func endLiveRefresh() {
refreshTimer?.invalidate()
refreshTimer = nil
cl.stopUpdatingLocation() cl.stopUpdatingLocation()
} }
@@ -94,6 +117,24 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
if let data = try? JSONEncoder().encode(channel) { if let data = try? JSONEncoder().encode(channel) {
UserDefaults.standard.set(data, forKey: self.userDefaultsKey) UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
} }
// Update teleported flag based on persisted state for immediate UI behavior
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
func markTeleported(for geohash: String, _ flag: Bool) {
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
}
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
} }
} }
@@ -123,6 +164,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
guard let loc = locations.last else { return } guard let loc = locations.last else { return }
lastLocation = loc lastLocation = loc
computeChannels(from: loc.coordinate) computeChannels(from: loc.coordinate)
reverseGeocodeIfNeeded(location: loc)
} }
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
@@ -151,7 +193,67 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh)) result.append(GeohashChannel(level: level, geohash: gh))
} }
Task { @MainActor in self.availableChannels = result } Task { @MainActor in
self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash)
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
self.teleported = persisted || (currentGH != ch.geohash)
}
}
}
private func reverseGeocodeIfNeeded(location: CLLocation) {
// Always cancel previous to keep latest fresh while user moves
geocoder.cancelGeocode()
isGeocoding = true
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
guard let self = self else { return }
self.isGeocoding = false
if let pm = placemarks?.first {
let names = self.namesByLevel(from: pm)
Task { @MainActor in self.locationNames = names }
}
}
}
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
// Region (country)
if let country = pm.country, !country.isEmpty {
dict[.region] = country
}
// Province (state/province or county)
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.province] = subAdmin
}
// City (locality)
if let locality = pm.locality, !locality.isEmpty {
dict[.city] = locality
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.city] = subAdmin
} else if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.city] = admin
}
// Neighborhood
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.neighborhood] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality
}
// Block: reuse neighborhood/locality granularity without exposing street level
if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality
}
return dict
} }
} }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -86,7 +86,7 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("DONE") { Button("close") {
dismiss() dismiss()
} }
.foregroundColor(textColor) .foregroundColor(textColor)
+448 -107
View File
@@ -25,32 +25,7 @@ struct PeerDisplayData: Identifiable {
let isMutualFavorite: Bool let isMutualFavorite: Bool
} }
// MARK: - Lazy Link Preview // (Link previews removed; URLs are now clickable inline)
// Lazy loading wrapper for link previews
struct LazyLinkPreviewView: View {
let url: URL
let title: String?
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
if isVisible {
LinkPreviewView(url: url, title: title)
} else {
// Placeholder while not visible
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.1))
.frame(height: 80)
.onAppear {
// Only load when view appears on screen
isVisible = true
}
}
}
.frame(height: 80)
}
}
// MARK: - Main Content View // MARK: - Main Content View
@@ -77,10 +52,16 @@ struct ContentView: View {
@State private var selectedMessageSender: String? @State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String? @State private var selectedMessageSenderID: String?
@FocusState private var isNicknameFieldFocused: Bool @FocusState private var isNicknameFieldFocused: Bool
@State private var isAtBottomPublic: Bool = true
@State private var isAtBottomPrivate: Bool = true
@State private var lastScrollTime: Date = .distantPast @State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer? @State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false @State private var showLocationChannelsSheet = false
@State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
// MARK: - Computed Properties // MARK: - Computed Properties
@@ -103,6 +84,10 @@ struct ContentView: View {
ZStack { ZStack {
// Base layer - Main public chat (always visible) // Base layer - Main public chat (always visible)
mainChatView mainChatView
.onAppear { viewModel.currentColorScheme = colorScheme }
.onChange(of: colorScheme) { newValue in
viewModel.currentColorScheme = newValue
}
// Private chat slide-over // Private chat slide-over
if viewModel.selectedPrivateChatPeer != nil { if viewModel.selectedPrivateChatPeer != nil {
@@ -204,7 +189,15 @@ struct ContentView: View {
isPresented: $showMessageActions, isPresented: $showMessageActions,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("private message") { Button("mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused = true
}
}
Button("direct message") {
if let peerID = selectedMessageSenderID { if let peerID = selectedMessageSenderID {
#if os(iOS) #if os(iOS)
if peerID.hasPrefix("nostr:") { if peerID.hasPrefix("nostr:") {
@@ -237,9 +230,18 @@ struct ContentView: View {
} }
Button("BLOCK", role: .destructive) { Button("BLOCK", role: .destructive) {
if let sender = selectedMessageSender { // Prefer direct geohash block when we have a Nostr sender ID
#if os(iOS)
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)") viewModel.sendMessage("/block \(sender)")
} }
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
} }
Button("cancel", role: .cancel) {} Button("cancel", role: .cancel) {}
@@ -265,7 +267,7 @@ struct ContentView: View {
// MARK: - Message List View // MARK: - Message List View
private func messagesView(privatePeer: String?) -> some View { private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
@@ -279,27 +281,48 @@ struct ContentView: View {
} }
}() }()
// Implement windowing - show last 100 messages for performance // Implement windowing with adjustable window count per chat
let windowedMessages = messages.suffix(100) let currentWindowCount: Int = {
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
return windowCountPublic
}()
let windowedMessages = messages.suffix(currentWindowCount)
ForEach(windowedMessages, id: \.id) { message in // Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(items, id: \.uiID) { item in
let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
if message.sender == "system" { if message.sender == "system" {
// System messages // System messages
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} else { } else {
// Regular messages with natural text wrapping // Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages // Delivery status indicator for private messages
@@ -310,55 +333,290 @@ struct ContentView: View {
} }
} }
// Check for plain URLs // Expand/Collapse for very long messages
let urls = message.content.extractURLs() if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
if !urls.isEmpty { let isExpanded = expandedMessageIDs.contains(message.id)
ForEach(urls.prefix(3).indices, id: \.self) { index in Button(isExpanded ? "show less" : "show more") {
let urlInfo = urls[index] if isExpanded { expandedMessageIDs.remove(message.id) }
LazyLinkPreviewView(url: urlInfo.url, title: nil) else { expandedMessageIDs.insert(message.id) }
.padding(.top, 3) }
.padding(.horizontal, 1) .font(.system(size: 11, weight: .medium, design: .monospaced))
.id("\(message.id)-\(urlInfo.url.absoluteString)") .foregroundColor(Color.blue)
.padding(.top, 4)
}
// Render payment chips (Lightning / Cashu) with rounded background
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
HStack(spacing: 8) {
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
let link = lightningLinks[i]
PaymentChipView(
emoji: "",
label: "pay via lightning",
colorScheme: colorScheme
) {
#if os(iOS)
if let url = URL(string: link) { UIApplication.shared.open(url) }
#else
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
#endif
}
}
ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { i in
let token = cashuTokens[i]
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
let urlStr = "cashu:\(enc)"
PaymentChipView(
emoji: "🥜",
label: "pay via cashu",
colorScheme: colorScheme
) {
#if os(iOS)
if let url = URL(string: urlStr) { UIApplication.shared.open(url) }
#else
if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) }
#endif
}
}
}
.padding(.top, 6)
.padding(.leading, 2)
}
}
}
}
.id(item.uiID)
.onAppear {
// Track if last item is visible to enable auto-scroll only when near bottom
if message.id == windowedMessages.last?.id {
isAtBottom.wrappedValue = true
}
// Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = 200
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? 300
let newCount = min(messages.count, current + step)
if newCount != current {
windowCountPrivate[peer] = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
} else {
let current = windowCountPublic
let newCount = min(messages.count, current + step)
if newCount != current {
windowCountPublic = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
} }
} }
} }
} }
} }
.id(message.id) .onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom.wrappedValue = false
}
}
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
// Only show actions for messages from other users (not system or self) // Tap on message body: insert @mention for this sender
if message.sender != "system" && message.sender != viewModel.nickname { if message.sender != "system" {
selectedMessageSender = message.sender let name = message.sender
selectedMessageSenderID = message.senderPeerID messageText = "@\(name) "
showMessageActions = true isTextFieldFocused = true
}
}
.contextMenu {
Button("Copy message") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 2) .padding(.vertical, 2)
} }
} }
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
.background(backgroundColor) .background(backgroundColor)
.onOpenURL { url in
guard url.scheme == "bitchat", url.host == "user" else { return }
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = id.removingPercentEncoding ?? id
selectedMessageSenderID = peerID
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender
showMessageActions = true
}
.onOpenURL { url in
guard url.scheme == "bitchat", url.host == "geohash" else { return }
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
#if os(iOS)
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true)
LocationChannelManager.shared.select(ChannelID.location(ch))
#endif
}
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// Triple-tap to clear current chat // Triple-tap to clear current chat
viewModel.sendMessage("/clear") viewModel.sendMessage("/clear")
} }
.onAppear {
// Force scroll to bottom when opening a chat view
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
// Second pass after a brief delay to handle late layout
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let targetID2: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) }
}
}
.onChange(of: privatePeer) { _ in
// When switching to a different private chat, jump to bottom
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
}
.onChange(of: viewModel.messages.count) { _ in .onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty { if privatePeer == nil && !viewModel.messages.isEmpty {
// If the newest message is from me, always scroll to bottom
let lastMsg = viewModel.messages.last!
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf {
// Only autoscroll when user is at/near bottom
guard isAtBottom.wrappedValue else { return }
} else {
// Ensure we consider ourselves at bottom for subsequent messages
isAtBottom.wrappedValue = true
}
// Throttle scroll animations to prevent excessive UI updates // Throttle scroll animations to prevent excessive UI updates
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed // Immediate scroll if enough time has passed
lastScrollTime = now lastScrollTime = now
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom) #if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else { } else {
// Schedule a delayed scroll // Schedule a delayed scroll
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom) #if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} }
} }
} }
@@ -367,20 +625,59 @@ struct ContentView: View {
if let peerID = privatePeer, if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID], let messages = viewModel.privateChats[peerID],
!messages.isEmpty { !messages.isEmpty {
// If the newest private message is from me, always scroll
let lastMsg = messages.last!
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf {
// Only autoscroll when user is at/near bottom
guard isAtBottom.wrappedValue else { return }
} else {
isAtBottom.wrappedValue = true
}
// Same throttling for private chats // Same throttling for private chats
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now lastScrollTime = now
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom) let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else { } else {
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom) let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} }
} }
} }
} }
#if os(iOS)
.onChange(of: locationManager.selectedChannel) { newChannel in
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
switch newChannel {
case .mesh:
break
case .location(let ch):
// Reset window size
windowCountPublic = 300
let contextKey = "geo:\(ch.geohash)"
let last = viewModel.messages.suffix(windowCountPublic).last?.id
let target = last.map { "\(contextKey)|\($0)" }
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
#endif
.onAppear { .onAppear {
// Also check when view appears // Also check when view appears
if let peerID = privatePeer { if let peerID = privatePeer {
@@ -397,6 +694,19 @@ struct ContentView: View {
} }
} }
} }
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
} }
// MARK: - Input View // MARK: - Input View
@@ -508,10 +818,7 @@ struct ContentView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
.autocorrectionDisabled(true) // iOS keyboard autocomplete and capitalization enabled by default
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Cancel previous debounce timer // Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
@@ -695,7 +1002,7 @@ struct ContentView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
mainHeaderView mainHeaderView
Divider() Divider()
messagesView(privatePeer: nil) messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
Divider() Divider()
inputView inputView
} }
@@ -745,7 +1052,7 @@ struct ContentView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
privateHeaderView privateHeaderView
Divider() Divider()
messagesView(privatePeer: viewModel.selectedPrivateChatPeer) messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
Divider() Divider()
inputView inputView
} }
@@ -834,52 +1141,57 @@ struct ContentView: View {
Spacer() Spacer()
// People counter with unread indicator // Channel badge + dynamic spacing + people counter
HStack(spacing: 4) { // Precompute header count and color outside the ViewBuilder expressions
#if os(iOS)
let cc = channelPeopleCountAndColor()
let headerCountColor: Color = cc.1
let headerOtherPeersCount: Int = {
if case .location = locationManager.selectedChannel {
return viewModel.visibleGeohashPeople().count
}
return cc.0
}()
#else
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let headerOtherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
HStack(spacing: 10) {
// Unread icon immediately to the left of the channel badge (independent from channel button)
// Unread indicator
#if os(iOS)
if viewModel.hasAnyUnreadMessages { if viewModel.hasAnyUnreadMessages {
Image(systemName: "envelope.fill") Button(action: { viewModel.openMostRelevantPrivateChat() }) {
.font(.system(size: 12)) Image(systemName: "envelope.fill")
.foregroundColor(Color.orange) .font(.system(size: 12))
.accessibilityLabel("Unread private messages") .foregroundColor(Color.orange)
}
.buttonStyle(.plain)
.accessibilityLabel("Open unread private chat")
} }
// People count depends on active channel
#if os(iOS)
let cc = channelPeopleCountAndColor()
let otherPeersCount = cc.0
let countColor = cc.1
#else
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let otherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
// Location channels button '#' // Location channels button '#'
#if os(iOS)
Button(action: { showLocationChannelsSheet = true }) { Button(action: { showLocationChannelsSheet = true }) {
#if os(iOS)
let badgeText: String = { let badgeText: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: case .mesh: return "#mesh"
return "#mesh" case .location(let ch): return "#\(ch.geohash)"
case .location(let ch):
return "#\(ch.geohash)"
} }
}() }()
let badgeColor: Color = { let badgeColor: Color = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: case .mesh:
// Darker, more neutral blue (less purple hue)
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location: case .location:
// Standard green to avoid overly bright appearance in light mode
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
}() }()
@@ -887,31 +1199,23 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor) .foregroundColor(badgeColor)
.lineLimit(1) .lineLimit(1)
.truncationMode(.head) .fixedSize(horizontal: true, vertical: false)
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing) .layoutPriority(2)
.fixedSize(horizontal: false, vertical: false)
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
#else
Text("#")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.accessibilityLabel("location channels")
#endif
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, 6)
#endif #endif
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.system(size: 11)) .font(.system(size: 11))
.accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")") .accessibilityLabel("\(headerOtherPeersCount) people")
Text("\(otherPeersCount)") Text("\(headerOtherPeersCount)")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(countColor) .foregroundColor(headerCountColor)
} }
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
@@ -1095,6 +1399,44 @@ struct ContentView: View {
// MARK: - Helper Views // MARK: - Helper Views
// Rounded payment chip button
private struct PaymentChipView: View {
let emoji: String
let label: String
let colorScheme: ColorScheme
let action: () -> Void
private var fgColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
}
private var border: Color { fgColor.opacity(0.25) }
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(emoji)
Text(label)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(bgColor)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(border, lineWidth: 1)
)
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
}
}
// Helper view for rendering message content (plain, no hashtag/mention formatting) // Helper view for rendering message content (plain, no hashtag/mention formatting)
struct MessageContentView: View { struct MessageContentView: View {
let message: BitchatMessage let message: BitchatMessage
@@ -1106,7 +1448,6 @@ struct MessageContentView: View {
Text(message.content) Text(message.content)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular) .fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
} }
// MARK: - Helper Methods // MARK: - Helper Methods
+109 -26
View File
@@ -6,46 +6,93 @@ struct GeohashPeopleList: View {
let textColor: Color let textColor: Color
let secondaryTextColor: Color let secondaryTextColor: Color
let onTapPerson: () -> Void let onTapPerson: () -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
var body: some View { var body: some View {
Group { if viewModel.visibleGeohashPeople().isEmpty {
if viewModel.geohashPeople.isEmpty { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
} else { }
let myHex: String? = { } else {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, let myHex: String? = {
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
return id.publicKeyHex.lowercased() let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
} return id.publicKeyHex.lowercased()
return nil
}()
let ordered = viewModel.geohashPeople.sorted { a, b in
if let me = myHex {
if a.id == me && b.id != me { return true }
if b.id == me && a.id != me { return false }
}
return a.lastSeen > b.lastSeen
} }
ForEach(ordered) { person in return nil
}()
let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id }
#if os(iOS)
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false
}
#else
let isTeleportedID: (String) -> Bool = { _ in false }
#endif
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) }
let tele = displayIDs.filter { isTeleportedID($0) }
let finalOrder: [String] = nonTele + tele
let firstID = finalOrder.first
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
VStack(alignment: .leading, spacing: 0) {
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
let person = personByID[pid]!
HStack(spacing: 4) { HStack(spacing: 4) {
let convKey = "nostr_" + String(person.id.prefix(16)) let isMe = (person.id == myHex)
if viewModel.unreadPrivateMessages.contains(convKey) { #if os(iOS)
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange) let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
} else { #else
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor) let teleported = false
#endif
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
let (base, suffix) = splitSuffix(from: person.displayName)
HStack(spacing: 0) {
Text(base)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
if isMe {
Text(" (you)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(rowColor)
}
}
if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
Image(systemName: "nosign")
.font(.system(size: 10))
.foregroundColor(.red)
.help("Blocked in geochash")
}
} }
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
.font(.system(size: 14, design: .monospaced))
.fontWeight(person.id == myHex ? .bold : .regular)
.foregroundColor(textColor)
Spacer() Spacer()
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, person.id == firstID ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
if person.id != myHex { if person.id != myHex {
@@ -53,10 +100,46 @@ struct GeohashPeopleList: View {
onTapPerson() onTapPerson()
} }
} }
.contextMenu {
if let me = myHex, person.id == me {
EmptyView()
} else {
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
if blocked {
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
} else {
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
}
}
}
} }
} }
// Seed and update order outside result builder
.onAppear {
orderedIDs = currentIDs
}
.onChange(of: currentIDs) { ids in
var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
if newOrder != orderedIDs { orderedIDs = newOrder }
}
} }
} }
} }
#endif #endif
// Helper to split a trailing #abcd suffix
#if os(iOS)
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
#endif
-434
View File
@@ -1,434 +0,0 @@
//
// LinkPreviewView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(iOS)
import LinkPresentation
import UIKit
#endif
// MARK: - Link Metadata Cache
/// Cache for link metadata to prevent repeated network requests
private class LinkMetadataCache {
static let shared = LinkMetadataCache()
#if os(iOS)
private let cache = NSCache<NSURL, CachedMetadata>()
private let imageCache = NSCache<NSURL, UIImage>()
#endif
private let queue = DispatchQueue(label: "chat.bitchat.linkmetadata.cache", attributes: .concurrent)
private init() {
#if os(iOS)
cache.countLimit = 100 // Keep metadata for up to 100 URLs
imageCache.countLimit = 50 // Keep images for up to 50 URLs
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB limit for images
#endif
}
#if os(iOS)
class CachedMetadata {
let metadata: LPLinkMetadata?
let title: String?
let host: String?
let error: Error?
let timestamp: Date
init(metadata: LPLinkMetadata? = nil, title: String? = nil, host: String? = nil, error: Error? = nil) {
self.metadata = metadata
self.title = title
self.host = host
self.error = error
self.timestamp = Date()
}
}
func getCachedMetadata(for url: URL) -> (metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?)? {
return queue.sync {
guard let cached = cache.object(forKey: url as NSURL) else { return nil }
// Check if cache is older than 24 hours
if Date().timeIntervalSince(cached.timestamp) > 86400 {
cache.removeObject(forKey: url as NSURL)
imageCache.removeObject(forKey: url as NSURL)
return nil
}
let image = imageCache.object(forKey: url as NSURL)
return (cached.metadata, cached.title, cached.host, image)
}
}
func cacheMetadata(_ metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?, for url: URL) {
queue.async(flags: .barrier) {
let cached = CachedMetadata(metadata: metadata, title: title, host: host)
self.cache.setObject(cached, forKey: url as NSURL)
if let image = image {
let cost = Int(image.size.width * image.size.height * 4) // Approximate memory usage
self.imageCache.setObject(image, forKey: url as NSURL, cost: cost)
}
}
}
func cacheError(_ error: Error, for url: URL) {
queue.async(flags: .barrier) {
let cached = CachedMetadata(error: error)
self.cache.setObject(cached, forKey: url as NSURL)
}
}
#endif
func clearCache() {
queue.async(flags: .barrier) {
#if os(iOS)
self.cache.removeAllObjects()
self.imageCache.removeAllObjects()
#endif
}
}
}
// MARK: - Link Preview View
struct LinkPreviewView: View {
let url: URL
let title: String?
@Environment(\.colorScheme) var colorScheme
#if os(iOS)
@State private var metadata: LPLinkMetadata?
@State private var cachedTitle: String?
@State private var cachedHost: String?
@State private var isLoading = false
#endif
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var borderColor: Color {
textColor.opacity(0.3)
}
var body: some View {
// Always use our custom compact view for consistent appearance
compactLinkView
.onAppear {
loadFromCacheOrFetch()
}
}
#if os(iOS)
@State private var previewImage: UIImage? = nil
#endif
private var compactLinkView: some View {
Button(action: {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}) {
HStack(spacing: 12) {
// Preview image or icon
Group {
#if os(iOS)
if let image = previewImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
} else {
// Favicon or default icon
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "link")
.font(.system(size: 24))
.foregroundColor(Color.blue)
)
}
#else
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "link")
.font(.system(size: 24))
.foregroundColor(Color.blue)
)
#endif
}
VStack(alignment: .leading, spacing: 4) {
// Title
#if os(iOS)
Text(cachedTitle ?? metadata?.title ?? title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
#else
Text(title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
#endif
// Host
#if os(iOS)
Text(cachedHost ?? url.host ?? url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor.opacity(0.6))
.lineLimit(1)
#else
Text(url.host ?? url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor.opacity(0.6))
.lineLimit(1)
#endif
}
Spacer()
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private var simpleLinkView: some View {
Button(action: {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}) {
HStack(spacing: 12) {
// Link icon
Image(systemName: "link.circle.fill")
.font(.system(size: 32))
.foregroundColor(Color.blue.opacity(0.8))
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 4) {
// Title
Text(title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
// URL
Text(url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color.blue)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
// Arrow indicator
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(textColor.opacity(0.5))
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func loadFromCacheOrFetch() {
#if os(iOS)
// Check if we already have data in state
guard metadata == nil && !isLoading else {
return
}
// Check cache first
if let cached = LinkMetadataCache.shared.getCachedMetadata(for: url) {
// print("🔗 LinkPreviewView: Using CACHED metadata for: \(url.absoluteString)")
self.metadata = cached.metadata
self.cachedTitle = cached.title ?? cached.metadata?.title
self.cachedHost = cached.host ?? url.host
self.previewImage = cached.image
return
}
// Not in cache, fetch it
// print("🔗 LinkPreviewView: FETCHING metadata for: \(url.absoluteString)")
isLoading = true
let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: url) { fetchedMetadata, error in
DispatchQueue.main.async {
self.isLoading = false
if let error = error {
// Check if it's an ATS error for subresources (non-critical)
let errorString = error.localizedDescription.lowercased()
let isATSError = errorString.contains("app transport security") ||
errorString.contains("secure connection")
if !isATSError {
// Only log non-ATS errors
// print("🔗 LinkPreviewView: Error fetching metadata: \(error)")
}
// Still try to show basic preview with URL info
self.cachedTitle = self.title ?? self.url.host
self.cachedHost = self.url.host
// Cache even failed attempts to avoid repeated fetches
LinkMetadataCache.shared.cacheMetadata(
nil,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
return
}
if let fetchedMetadata = fetchedMetadata {
// Use the fetched metadata, or create new with our title
if let title = self.title, !title.isEmpty {
fetchedMetadata.title = title
}
self.metadata = fetchedMetadata
self.cachedTitle = fetchedMetadata.title ?? self.title
self.cachedHost = self.url.host
// Try to extract image
if let imageProvider = fetchedMetadata.imageProvider {
imageProvider.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.async {
if let image = image as? UIImage {
self.previewImage = image
// Cache everything including the image
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: image,
for: self.url
)
} else {
// Cache without image
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
}
}
}
} else {
// No image, cache what we have
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
}
}
}
}
#endif
}
}
#if os(iOS)
// UIViewRepresentable wrapper for LPLinkView
struct LinkPreview: UIViewRepresentable {
let metadata: LPLinkMetadata
func makeUIView(context: Context) -> UIView {
let containerView = UIView()
containerView.backgroundColor = .clear
let linkView = LPLinkView(metadata: metadata)
linkView.isUserInteractionEnabled = false // We handle taps at the SwiftUI level
linkView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(linkView)
NSLayoutConstraint.activate([
linkView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
linkView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
linkView.topAnchor.constraint(equalTo: containerView.topAnchor),
linkView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
return containerView
}
func updateUIView(_ uiView: UIView, context: Context) {
// Update if needed
}
}
#endif
// Helper to extract URLs from text
extension String {
func extractURLs() -> [(url: URL, range: Range<String.Index>)] {
var urls: [(URL, Range<String.Index>)] = []
// Check for plain URLs
let types: NSTextCheckingResult.CheckingType = .link
if let detector = try? NSDataDetector(types: types.rawValue) {
let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
for match in matches {
if let range = Range(match.range, in: self),
let url = match.url {
urls.append((url, range))
}
}
}
return urls
}
}
#Preview {
VStack {
LinkPreviewView(url: URL(string: "https://example.com")!, title: "Example Website")
.padding()
}
}
+121 -13
View File
@@ -93,7 +93,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View { private var channelList: some View {
List { List {
// Mesh option first // Mesh option first
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) { channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth\(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh) manager.select(ChannelID.mesh)
isPresented = false isPresented = false
} }
@@ -101,7 +101,14 @@ struct LocationChannelsSheet: View {
// Nearby options // Nearby options
if !manager.availableChannels.isEmpty { if !manager.availableChannels.isEmpty {
ForEach(manager.availableChannels) { channel in ForEach(manager.availableChannels) { channel in
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) { let coverage = coverageString(forPrecision: channel.geohash.count)
let nameBase = locationName(for: channel.level)
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
// Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel)) manager.select(ChannelID.location(channel))
isPresented = false isPresented = false
} }
@@ -140,13 +147,22 @@ struct LocationChannelsSheet: View {
} }
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized) let isValid = validateGeohash(normalized)
Button("teleport") { Button(action: {
let gh = normalized let gh = normalized
guard isValid else { customError = "invalid geohash"; return } guard isValid else { customError = "invalid geohash"; return }
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
// Mark this selection as a manual teleport
manager.markTeleported(for: ch.geohash, true)
manager.select(ChannelID.location(ch)) manager.select(ChannelID.location(ch))
isPresented = false isPresented = false
}) {
HStack(spacing: 6) {
Text("teleport")
.font(.system(size: 14, design: .monospaced))
Image(systemName: "face.dashed")
.font(.system(size: 14))
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -198,7 +214,7 @@ struct LocationChannelsSheet: View {
return false return false
} }
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View { private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -207,15 +223,28 @@ struct LocationChannelsSheet: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(parts.base) Text(parts.base)
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
Text(subtitle) HStack(spacing: 0) {
.font(.system(size: 12, design: .monospaced)) Text(subtitlePrefix)
.foregroundColor(.secondary) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
}
} }
Spacer() Spacer()
if isSelected { if isSelected {
@@ -241,13 +270,17 @@ struct LocationChannelsSheet: View {
// MARK: - Helpers for counts // MARK: - Helpers for counts
private func meshTitleWithCount() -> String { private func meshTitleWithCount() -> String {
// Count currently connected mesh peers (excluding self) // Count currently connected mesh peers (excluding self)
let meshCount = meshCount()
let noun = meshCount == 1 ? "person" : "people"
return "mesh [\(meshCount) \(noun)]"
}
private func meshCount() -> Int {
let myID = viewModel.meshService.myPeerID let myID = viewModel.meshService.myPeerID
let meshCount = viewModel.allPeers.reduce(0) { acc, peer in return viewModel.allPeers.reduce(0) { acc, peer in
if peer.id != myID && peer.isConnected { return acc + 1 } if peer.id != myID && peer.isConnected { return acc + 1 }
return acc return acc
} }
let noun = meshCount == 1 ? "person" : "people"
return "#mesh [\(meshCount) \(noun)]"
} }
private func geohashTitleWithCount(for channel: GeohashChannel) -> String { private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
@@ -265,12 +298,12 @@ struct LocationChannelsSheet: View {
private func levelForLength(_ len: Int) -> GeohashChannelLevel { private func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len { switch len {
case 0...2: return .country case 0...2: return .region
case 3...4: return .region case 3...4: return .province
case 5: return .city case 5: return .city
case 6: return .neighborhood case 6: return .neighborhood
case 7: return .block case 7: return .block
default: return .street default: return .block
} }
} }
} }
@@ -280,6 +313,81 @@ extension LocationChannelsSheet {
private var standardGreen: Color { private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
} }
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
}
// MARK: - Coverage helpers
extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String {
// Approximate max cell dimension at equator for a given geohash length.
// Values sourced from common geohash dimension tables.
let maxMeters: Double = {
switch len {
case 2: return 1_250_000
case 3: return 156_000
case 4: return 39_100
case 5: return 4_890
case 6: return 1_220
case 7: return 153
case 8: return 38.2
case 9: return 4.77
case 10: return 1.19
default:
if len <= 1 { return 5_000_000 }
// For >10, scale down conservatively by ~1/4 each char
let over = len - 10
return 1.19 * pow(0.25, Double(over))
}
}()
let usesMetric: Bool = {
if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
if usesMetric {
let km = maxMeters / 1000.0
return "~\(formatDistance(km)) km"
} else {
let miles = maxMeters / 1609.344
return "~\(formatDistance(miles)) mi"
}
}
private func formatDistance(_ value: Double) -> String {
if value >= 100 { return String(format: "%.0f", value.rounded()) }
if value >= 10 { return String(format: "%.1f", value) }
return String(format: "%.1f", value)
}
private func bluetoothRangeString() -> String {
let usesMetric: Bool = {
if #available(iOS 16.0, *) {
return Locale.current.measurementSystem == .metric
} else {
return Locale.current.usesMetricSystem
}
}()
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
return usesMetric ? "~1050 m" : "~30160 ft"
}
private func locationName(for level: GeohashChannelLevel) -> String? {
manager.locationNames[level]
}
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
switch level {
case .region:
return ""
default:
return "~"
}
}
} }
#endif #endif
+70 -40
View File
@@ -7,67 +7,73 @@ struct MeshPeerList: View {
let onTapPeer: (String) -> Void let onTapPeer: (String) -> Void
let onToggleFavorite: (String) -> Void let onToggleFavorite: (String) -> Void
let onShowFingerprint: (String) -> Void let onShowFingerprint: (String) -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
var body: some View { var body: some View {
Group { if viewModel.allPeers.isEmpty {
if viewModel.allPeers.isEmpty { VStack(alignment: .leading, spacing: 0) {
Text("nobody around...") Text("nobody around...")
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 12) .padding(.top, 12)
} else { }
let peerNicknames = viewModel.meshService.getPeerNicknames() } else {
let myPeerID = viewModel.meshService.myPeerID let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.id == myPeerID let isMe = peer.id == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.id) let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
let enc = viewModel.getEncryptionStatus(for: peer.id) let enc = viewModel.getEncryptionStatus(for: peer.id)
return (peer, isMe, hasUnread, enc) return (peer, isMe, hasUnread, enc)
} }
let peers = mapped.sorted { lhs, rhs in // Stable visual order without mutating state here
let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false let currentIDs = mapped.map { $0.peer.id }
let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
if lFav != rFav { return lFav } let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname mapped.first(where: { $0.peer.id == id })
let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname }
return lhsName < rhsName
}
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx] let item = peers[idx]
let peer = item.peer let peer = item.peer
let isMe = item.isMe let isMe = item.isMe
let hasUnread = item.hasUnread
HStack(spacing: 4) { HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor) Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor)
} else if hasUnread {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
} else { } else {
switch peer.connectionState { Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor)
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 10)).foregroundColor(textColor)
case .nostrAvailable:
Image(systemName: "globe").font(.system(size: 10)).foregroundColor(.purple)
case .offline:
if peer.favoriteStatus?.isFavorite ?? false {
Image(systemName: "moon.fill").font(.system(size: 10)).foregroundColor(.gray)
} else {
Image(systemName: "person").font(.system(size: 10)).foregroundColor(.gray)
}
}
} }
let displayName = isMe ? viewModel.nickname : peer.nickname let displayName = isMe ? viewModel.nickname : peer.nickname
Text(displayName) let (base, suffix) = splitSuffix(from: displayName)
.font(.system(size: 14, design: .monospaced)) HStack(spacing: 0) {
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) Text(base)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(baseColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
}
if !isMe, viewModel.isPeerBlocked(peer.id) {
Image(systemName: "nosign")
.font(.system(size: 10))
.foregroundColor(.red)
.help("Blocked")
}
if let icon = item.enc.icon, !isMe { if let icon = item.enc.icon, !isMe {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red)) .foregroundColor(baseColor)
} }
Spacer() Spacer()
@@ -83,12 +89,36 @@ struct MeshPeerList: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 6 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } } .onTapGesture { if !isMe { onTapPeer(peer.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } } .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
} }
} }
// Seed and update order outside result builder
.onAppear {
let currentIDs = mapped.map { $0.peer.id }
orderedIDs = currentIDs
}
.onChange(of: mapped.map { $0.peer.id }) { ids in
var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
if newOrder != orderedIDs { orderedIDs = newOrder }
}
} }
} }
} }
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
+80
View File
@@ -0,0 +1,80 @@
//
// MessageTextHelpers.swift
// Shared text parsing helpers for message rendering.
//
import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
var current = 0
for ch in self {
if ch.isWhitespace || ch.isNewline {
if current >= threshold { return true }
current = 0
} else {
current += 1
if current >= threshold { return true }
}
}
return current >= threshold
}
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuTokens(max: Int = 3) -> [String] {
let regex = RegexCache.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
for m in regex.matches(in: self, options: [], range: range) {
if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
found.append(token)
if found.count >= max { break }
}
}
return found
}
// Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning:<payload>
func extractLightningLinks(max: Int = 3) -> [String] {
var results: [String] = []
let ns = self as NSString
let full = NSRange(location: 0, length: ns.length)
// lightning: scheme
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
// BOLT11
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
// LNURL bech32
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
return results
}
}
+2 -5
View File
@@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase {
// Sanity: known coords (Statue of Liberty approx) // Sanity: known coords (Statue of Liberty approx)
let lat = 40.6892 let lat = 40.6892
let lon = -74.0445 let lon = -74.0445
let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision)
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision) let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision) let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision) let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision) let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
XCTAssertEqual(street.count, 8)
XCTAssertEqual(block.count, 7) XCTAssertEqual(block.count, 7)
XCTAssertEqual(neighborhood.count, 6) XCTAssertEqual(neighborhood.count, 6)
XCTAssertEqual(city.count, 5) XCTAssertEqual(city.count, 5)
@@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase {
XCTAssertEqual(country.count, 2) XCTAssertEqual(country.count, 2)
// All prefixes must match progressively // All prefixes must match progressively
XCTAssertTrue(street.hasPrefix(block))
XCTAssertTrue(block.hasPrefix(neighborhood)) XCTAssertTrue(block.hasPrefix(neighborhood))
XCTAssertTrue(neighborhood.hasPrefix(city)) XCTAssertTrue(neighborhood.hasPrefix(city))
XCTAssertTrue(city.hasPrefix(region)) XCTAssertTrue(city.hasPrefix(region))