mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:25:21 +00:00
* 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>
300 lines
10 KiB
Swift
300 lines
10 KiB
Swift
import BitFoundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
struct MeshPeerRow: Identifiable, Equatable {
|
|
let peerID: PeerID
|
|
let displayName: String
|
|
let isMe: Bool
|
|
let hasUnread: Bool
|
|
let isBlocked: Bool
|
|
let isFavorite: Bool
|
|
let isConnected: Bool
|
|
let isReachable: Bool
|
|
let isMutualFavorite: Bool
|
|
let encryptionStatus: EncryptionStatus
|
|
let showsVerifiedBadgeWhenOffline: Bool
|
|
/// Vouched-for by someone I verified, without an explicit verification of
|
|
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
|
let showsVouchedBadge: Bool
|
|
|
|
var id: String { peerID.id }
|
|
}
|
|
|
|
struct GeohashPersonRow: Identifiable, Equatable {
|
|
let id: String
|
|
let displayName: String
|
|
let isMe: Bool
|
|
let isTeleported: Bool
|
|
let isBlocked: Bool
|
|
}
|
|
|
|
struct GroupChatRow: Identifiable, Equatable {
|
|
let peerID: PeerID
|
|
let name: String
|
|
let memberCount: Int
|
|
let isCreator: Bool
|
|
let hasUnread: Bool
|
|
|
|
var id: String { peerID.id }
|
|
}
|
|
|
|
@MainActor
|
|
final class PeerListModel: ObservableObject {
|
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
|
@Published private(set) var groupRows: [GroupChatRow] = []
|
|
@Published private(set) var reachableMeshPeerCount = 0
|
|
@Published private(set) var connectedMeshPeerCount = 0
|
|
@Published private(set) var visibleGeohashPeerCount = 0
|
|
@Published private(set) var renderID = ""
|
|
|
|
private let chatViewModel: ChatViewModel
|
|
private let conversations: ConversationStore
|
|
private let locationChannelsModel: LocationChannelsModel
|
|
private let peerIdentityStore: PeerIdentityStore
|
|
private let locationPresenceStore: LocationPresenceStore
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(
|
|
chatViewModel: ChatViewModel,
|
|
conversations: ConversationStore,
|
|
locationChannelsModel: LocationChannelsModel? = nil,
|
|
peerIdentityStore: PeerIdentityStore? = nil,
|
|
locationPresenceStore: LocationPresenceStore? = nil
|
|
) {
|
|
self.chatViewModel = chatViewModel
|
|
self.conversations = conversations
|
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
|
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
|
self.allPeers = chatViewModel.allPeers
|
|
|
|
bind()
|
|
refresh()
|
|
}
|
|
|
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
|
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
|
}
|
|
|
|
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
|
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
|
}
|
|
|
|
func participantCount(for geohash: String) -> Int {
|
|
chatViewModel.geohashParticipantCount(for: geohash)
|
|
}
|
|
|
|
func startConversation(with peerID: PeerID) {
|
|
chatViewModel.startPrivateChat(with: peerID)
|
|
}
|
|
|
|
func toggleFavorite(peerID: PeerID) {
|
|
chatViewModel.toggleFavorite(peerID: peerID)
|
|
}
|
|
|
|
func openGeohashDirectMessage(with pubkeyHex: String) {
|
|
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
|
}
|
|
|
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
|
chatViewModel.blockGeohashUser(
|
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
|
displayName: displayName
|
|
)
|
|
}
|
|
|
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
|
chatViewModel.unblockGeohashUser(
|
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
|
displayName: displayName
|
|
)
|
|
}
|
|
|
|
private func bind() {
|
|
chatViewModel.$allPeers
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] peers in
|
|
self?.allPeers = peers
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
chatViewModel.$nickname
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
locationPresenceStore.$teleportedGeo
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
conversations.$unreadConversations
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
chatViewModel.groupStore.$groups
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
peerIdentityStore.$encryptionStatuses
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
peerIdentityStore.$verifiedFingerprints
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
chatViewModel.participantTracker.$visiblePeople
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
locationChannelsModel.$selectedChannel
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
locationChannelsModel.$teleported
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
locationChannelsModel.$availableChannels
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refresh()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func refresh() {
|
|
let myPeerID = chatViewModel.meshService.myPeerID
|
|
let meshRows = allPeers.map { peer in
|
|
let isMe = peer.peerID == myPeerID
|
|
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
|
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
|
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
|
// Vouched is subordinate to verified: never show both seals.
|
|
let vouchedBadge = !isVerifiedFingerprint
|
|
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
|
|
|
return MeshPeerRow(
|
|
peerID: peer.peerID,
|
|
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
|
isMe: isMe,
|
|
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
|
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
|
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
|
isConnected: peer.isConnected,
|
|
isReachable: peer.isReachable,
|
|
isMutualFavorite: peer.isMutualFavorite,
|
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
|
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
|
showsVouchedBadge: vouchedBadge
|
|
)
|
|
}
|
|
|
|
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
|
guard !row.isMe else { return }
|
|
if row.isConnected {
|
|
counts.connected += 1
|
|
counts.reachable += 1
|
|
} else if row.isReachable {
|
|
counts.reachable += 1
|
|
}
|
|
}
|
|
|
|
let geohashPeople = buildGeohashPeople()
|
|
let groupRows = buildGroupRows()
|
|
|
|
self.meshRows = meshRows
|
|
reachableMeshPeerCount = meshCounts.reachable
|
|
connectedMeshPeerCount = meshCounts.connected
|
|
self.geohashPeople = geohashPeople
|
|
visibleGeohashPeerCount = geohashPeople.count
|
|
self.groupRows = groupRows
|
|
renderID = (
|
|
meshRows.map {
|
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
|
} +
|
|
geohashPeople.map {
|
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
|
} +
|
|
groupRows.map {
|
|
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
|
}
|
|
).joined(separator: "|")
|
|
}
|
|
|
|
private func buildGroupRows() -> [GroupChatRow] {
|
|
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
|
return chatViewModel.groupStore.groups.map { group in
|
|
GroupChatRow(
|
|
peerID: group.peerID,
|
|
name: group.name,
|
|
memberCount: group.members.count,
|
|
isCreator: group.creatorFingerprint == myFingerprint,
|
|
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
|
let myHex = currentGeohashIdentityHex()
|
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
|
|
|
return chatViewModel.visibleGeohashPeople().map { person in
|
|
let isMe = person.id == myHex
|
|
return GeohashPersonRow(
|
|
id: person.id,
|
|
displayName: person.displayName,
|
|
isMe: isMe,
|
|
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
|
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func currentGeohashIdentityHex() -> String? {
|
|
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
|
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
|
return nil
|
|
}
|
|
|
|
return identity.publicKeyHex.lowercased()
|
|
}
|
|
}
|