Files
bitchat/bitchat/Views/GroupChatList.swift
T
81a10f73f0 Private groups: creator-managed encrypted group chat over the mesh (#1383)
* 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>
2026-07-07 15:59:55 +02:00

87 lines
3.9 KiB
Swift

import BitFoundation
import SwiftUI
/// Compact "groups" section for the people sheet: one row per private group
/// this device belongs to, tappable to open the group chat window.
struct GroupChatList: View {
@ThemedPalette private var palette
let groups: [GroupChatRow]
let onTapGroup: (PeerID) -> Void
private enum Strings {
static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list")
static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count")
}
var body: some View {
if !groups.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.header)
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 10)
.padding(.bottom, 2)
.accessibilityAddTraits(.isHeader)
ForEach(groups) { group in
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.primary)
Text("#\(group.name)")
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
.truncationMode(.tail)
Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)"))
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
if group.isCreator {
Image(systemName: "crown.fill")
.font(.bitchatSystem(size: 9))
.foregroundColor(.yellow)
.help(Strings.creator)
}
Spacer()
if group.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
.help(Strings.newMessagesTooltip)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapGroup(group.peerID) }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: group))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openGroupHint)
}
}
}
}
private func accessibilityDescription(for group: GroupChatRow) -> String {
var parts: [String] = [
group.name,
String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")
]
if group.isCreator { parts.append(Strings.creator) }
if group.hasUnread { parts.append(Strings.unread) }
return parts.joined(separator: ", ")
}
}