* Add capability bits to announce TLV
Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".
PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Private groups: creator-managed encrypted group chat over the mesh
Small encrypted crews (hard cap 16) between public broadcast and 1:1 DMs:
Protocol
- MessageType.groupMessage = 0x25: broadcast packets with a cleartext
16-byte group ID + epoch, ChaCha20-Poly1305 ciphertext (epoch bound as
AEAD AAD), inner Ed25519 sender signature over
"bitchat-group-msg-v1"|groupID|messageID|timestamp|content
- NoisePayloadType.groupInvite = 0x06 / .groupKeyUpdate = 0x07:
creator-signed group state (key, epoch, roster) 1:1 over Noise; signature
over "bitchat-group-v1"|groupID|epoch|key-hash|roster-hash and the Noise
session peer must BE the creator
- SyncTypeFlags bit 10 (groupMessage): variable-length LE bitfield widens
1 -> 2 bytes inside the length-prefixed REQUEST_SYNC TLV; old clients
ignore unknown bits and answer with types they know
- PeerCapabilities.localSupported now advertises .groups
Storage
- GroupStore: symmetric keys in the keychain, roster/name/epoch as
protected JSON in Application Support; wiped in panicClearAllData()
Behavior
- Non-members relay 0x25 like any broadcast but cannot read it; group
messages join gossip-sync backfill with the public-message window
- Receivers drop wrong-epoch envelopes, bad sender signatures, and
senders missing from the creator-signed roster
- Fire-and-flood delivery (no per-member acks in v1)
UI
- Groups open as chat windows through the private-chat sheet (virtual
"group_" peer IDs); groups section in the people sheet; /group
create/invite/remove/leave/list commands; invitees get a system message
+ notification and the group appears in their people sheet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Private groups: fix TLV truncation, roster downgrade, removal notice, block, media, signable bytes
Addresses the Codex review and adversarial-review findings on #1383:
- TLV encoding now throws GroupTLVError.valueTooLong instead of clamping to
65535 and truncating, so an oversize group message fails to seal and
surfaces send_failed rather than shipping ciphertext recipients drop.
- Roster nicknames truncate on a Character boundary (never mid-scalar), so a
multi-byte nickname can no longer make the whole signed roster undecodable.
- Invites now bump the epoch (rotate the key) like removals, giving every
roster change a strictly-increasing epoch so out-of-order invite states no
longer last-writer-wins a just-added member back out.
- Removing a member now sends them a creator-signed roster-without-them under
a throwaway all-zero key (never the rotated key), so their client
deactivates the group and surfaces "removed" instead of going silently dark.
- /block is enforced in the group receive path: a blocked member's messages
are dropped from display and notifications, consistent with every other
inbound path.
- Media affordances are disabled in group chats (both computed sites) so the
composer can't strand a media placeholder that never sends; media-in-groups
is a documented v2 item.
- Creator signature now covers the group name and the sender signature covers
the epoch (wire-format-affecting; needs Android parity before ship).
- Explicit isGroup guard in markPrivateMessagesAsRead so read/delivered
receipts can never leak into group conversations under a future refactor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Make peer lists accessible and actionable; block by stable identity
Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).
- Both peer lists become real accessibility citizens: each row is one
element announcing name, reachability, and favorite/unread/blocked
state, with a button trait and custom actions for the gesture-only
interactions. Neither file previously had a single accessibility
modifier. Reachability icons gain tooltips reusing existing strings;
teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
message, favorite, show fingerprint, block/unblock. The fingerprint
double-tap, previously shadowed by the single tap, is reordered so it
fires.
- The DM header's offline state (previously EmptyView — absence of a
glyph as the only signal) becomes a dimmed "offline" tag, and a
geohash DM — always Nostr-routed — no longer mislabels itself
"offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
instead of a `/block <displayName>` string, so the exact tapped row is
affected and offline peers can be unblocked (with covering tests).
New strings are added source-language (en) only.
* Surface block/unblock feedback in the conversation where it was triggered
setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.
Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).
The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove dead accessibility label and unreachable /unblock fallback
The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.
ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.
Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest 6.8k -> 13.7k msg/s (2.0x)
delivery updates 38k -> 117-133k/s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-conversation ObservableObjects with O(1) dedup via an incrementally
maintained message-ID index, binary-search timestamp insertion, folded
cap policies, a no-downgrade delivery rule, and a typed change subject.
All mutation flows through store intents (conversation mutators are
fileprivate). The previous store is renamed LegacyConversationStore
pending deletion in step 5. 16 behavioral tests including per-
conversation publish isolation; store.append benchmarks at ~144k
messages/sec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.
Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.
New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.
App-layer runtime/model files updated alongside.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>