mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +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>
363 lines
13 KiB
Swift
363 lines
13 KiB
Swift
import BitFoundation
|
|
import Combine
|
|
import Foundation
|
|
|
|
/// Feature model for private (direct) conversations.
|
|
///
|
|
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
|
|
/// returns the peer's conversation backing array (no mirror dictionary), and
|
|
/// the store's typed `changes` subject drives invalidation — a change in the
|
|
/// SELECTED peer's conversation republishes this model, while appends to
|
|
/// other private chats only surface through the unread set. Direct
|
|
/// conversations are keyed by raw routing peer ID; the coordinators'
|
|
/// ephemeral/stable mirroring guarantees the selected peer's key always
|
|
/// holds the full timeline (see `ConversationID.directPeer`).
|
|
@MainActor
|
|
final class PrivateInboxModel: ObservableObject {
|
|
@Published private(set) var selectedPeerID: PeerID?
|
|
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
|
|
|
private let conversations: ConversationStore
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(conversations: ConversationStore) {
|
|
self.conversations = conversations
|
|
self.selectedPeerID = conversations.selectedPrivatePeerID
|
|
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
|
|
|
bind()
|
|
}
|
|
|
|
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
|
guard let peerID else { return [] }
|
|
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
|
}
|
|
|
|
private func bind() {
|
|
conversations.$selectedPrivatePeerID
|
|
.dropFirst()
|
|
.sink { [weak self] peerID in
|
|
guard let self, self.selectedPeerID != peerID else { return }
|
|
self.selectedPeerID = peerID
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
conversations.changes
|
|
.sink { [weak self] change in
|
|
self?.apply(change)
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func apply(_ change: ConversationChange) {
|
|
switch change {
|
|
case .appended(let id, _),
|
|
.updated(let id, _),
|
|
.statusChanged(let id, _, _),
|
|
.messageRemoved(let id, _),
|
|
.cleared(let id):
|
|
republishIfSelected(id)
|
|
|
|
case .unreadChanged(let id, _):
|
|
guard isDirect(id) else { return }
|
|
refreshUnreadPeerIDs()
|
|
|
|
case .removed(let id):
|
|
guard isDirect(id) else { return }
|
|
refreshUnreadPeerIDs()
|
|
republishIfSelected(id)
|
|
|
|
case .migrated(let source, let destination):
|
|
guard isDirect(source) || isDirect(destination) else { return }
|
|
refreshUnreadPeerIDs()
|
|
republishIfSelected(source)
|
|
republishIfSelected(destination)
|
|
}
|
|
}
|
|
|
|
private func republishIfSelected(_ id: ConversationID) {
|
|
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
|
|
objectWillChange.send()
|
|
}
|
|
|
|
private func refreshUnreadPeerIDs() {
|
|
let next = conversations.unreadDirectRoutingPeerIDs()
|
|
guard unreadPeerIDs != next else { return }
|
|
unreadPeerIDs = next
|
|
}
|
|
|
|
private func isDirect(_ id: ConversationID) -> Bool {
|
|
if case .direct = id { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
enum PrivateConversationAvailability: Equatable {
|
|
case bluetoothConnected
|
|
case meshReachable
|
|
case nostrAvailable
|
|
case offline
|
|
}
|
|
|
|
struct PrivateConversationHeaderState: Equatable {
|
|
let conversationPeerID: PeerID
|
|
let headerPeerID: PeerID
|
|
let displayName: String
|
|
let availability: PrivateConversationAvailability
|
|
let isFavorite: Bool
|
|
let encryptionStatus: EncryptionStatus?
|
|
|
|
var supportsFavoriteToggle: Bool {
|
|
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
|
}
|
|
|
|
/// Group chats have no single peer identity behind the header: no
|
|
/// fingerprint screen, no per-peer encryption badge.
|
|
var isGroupConversation: Bool {
|
|
conversationPeerID.isGroup
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class PrivateConversationModel: ObservableObject {
|
|
@Published private(set) var selectedPeerID: PeerID?
|
|
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
|
|
|
private let chatViewModel: ChatViewModel
|
|
private let conversations: ConversationStore
|
|
private let locationChannelsModel: LocationChannelsModel
|
|
private let peerIdentityStore: PeerIdentityStore
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(
|
|
chatViewModel: ChatViewModel,
|
|
conversations: ConversationStore,
|
|
locationChannelsModel: LocationChannelsModel? = nil,
|
|
peerIdentityStore: PeerIdentityStore? = nil
|
|
) {
|
|
self.chatViewModel = chatViewModel
|
|
self.conversations = conversations
|
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
|
let initialPeerID = conversations.selectedPrivatePeerID
|
|
self.selectedPeerID = initialPeerID
|
|
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
|
makeHeaderState(for: peerID)
|
|
}
|
|
|
|
bind()
|
|
}
|
|
|
|
func startConversation(with peerID: PeerID) {
|
|
chatViewModel.startPrivateChat(with: peerID)
|
|
refreshSelectedConversation()
|
|
}
|
|
|
|
func openConversation(for peerID: PeerID) {
|
|
if peerID.isGeoChat {
|
|
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
|
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
|
} else {
|
|
chatViewModel.startPrivateChat(with: peerID)
|
|
}
|
|
|
|
refreshSelectedConversation()
|
|
}
|
|
|
|
func endConversation() {
|
|
chatViewModel.endPrivateChat()
|
|
refreshSelectedConversation()
|
|
}
|
|
|
|
func toggleFavorite(peerID: PeerID) {
|
|
chatViewModel.toggleFavorite(peerID: peerID)
|
|
refreshSelectedConversation()
|
|
}
|
|
|
|
func toggleFavoriteForSelectedConversation() {
|
|
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
|
toggleFavorite(peerID: headerPeerID)
|
|
}
|
|
|
|
func markMessagesAsRead(from peerID: PeerID) {
|
|
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
private func bind() {
|
|
conversations.$selectedPrivatePeerID
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
chatViewModel.$allPeers
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
peerIdentityStore.$encryptionStatuses
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
chatViewModel.groupStore.$groups
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
locationChannelsModel.$selectedChannel
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshSelectedConversation()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func refreshSelectedConversation() {
|
|
selectedPeerID = conversations.selectedPrivatePeerID
|
|
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
|
makeHeaderState(for: peerID)
|
|
}
|
|
}
|
|
|
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
|
// Group chats: the "peer" is the whole crew. Name + member count in
|
|
// the header; availability reads as mesh since group traffic floods
|
|
// the local mesh, and the per-peer encryption badge does not apply.
|
|
if conversationPeerID.isGroup {
|
|
let displayName: String
|
|
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
|
displayName = "#\(group.name) (\(group.members.count))"
|
|
} else {
|
|
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
|
}
|
|
return PrivateConversationHeaderState(
|
|
conversationPeerID: conversationPeerID,
|
|
headerPeerID: conversationPeerID,
|
|
displayName: displayName,
|
|
availability: .meshReachable,
|
|
isFavorite: false,
|
|
encryptionStatus: nil
|
|
)
|
|
}
|
|
|
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
|
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
|
// never resolve to a reachable mesh peer, so resolveAvailability would
|
|
// report .offline. Report .nostrAvailable so the header shows the
|
|
// globe instead of a misleading "offline" tag.
|
|
let availability = conversationPeerID.isGeoDM
|
|
? .nostrAvailable
|
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
|
? nil
|
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
|
|
|
return PrivateConversationHeaderState(
|
|
conversationPeerID: conversationPeerID,
|
|
headerPeerID: headerPeerID,
|
|
displayName: displayName,
|
|
availability: availability,
|
|
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
|
encryptionStatus: encryptionStatus
|
|
)
|
|
}
|
|
|
|
private func resolveDisplayName(
|
|
for conversationPeerID: PeerID,
|
|
headerPeerID: PeerID,
|
|
peer: BitchatPeer?
|
|
) -> String {
|
|
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
|
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
|
}
|
|
if let displayName = peer?.displayName {
|
|
return displayName
|
|
}
|
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
|
return nickname
|
|
}
|
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
|
for: Data(hexString: headerPeerID.id) ?? Data()
|
|
), !favorite.peerNickname.isEmpty {
|
|
return favorite.peerNickname
|
|
}
|
|
if headerPeerID.id.count == 16 {
|
|
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
|
if let identity = candidates.first,
|
|
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
|
if let pet = social.localPetname, !pet.isEmpty {
|
|
return pet
|
|
}
|
|
if !social.claimedNickname.isEmpty {
|
|
return social.claimedNickname
|
|
}
|
|
}
|
|
} else if let noiseKey = headerPeerID.noiseKey {
|
|
let fingerprint = noiseKey.sha256Fingerprint()
|
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
|
if let pet = social.localPetname, !pet.isEmpty {
|
|
return pet
|
|
}
|
|
if !social.claimedNickname.isEmpty {
|
|
return social.claimedNickname
|
|
}
|
|
}
|
|
}
|
|
|
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
|
}
|
|
|
|
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
|
if let connectionState = peer?.connectionState {
|
|
switch connectionState {
|
|
case .bluetoothConnected:
|
|
return .bluetoothConnected
|
|
case .meshReachable:
|
|
return .meshReachable
|
|
case .nostrAvailable:
|
|
return .nostrAvailable
|
|
case .offline:
|
|
return .offline
|
|
}
|
|
}
|
|
|
|
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
|
return .meshReachable
|
|
}
|
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
favoriteStatus.isMutual {
|
|
return .nostrAvailable
|
|
}
|
|
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
|
return .bluetoothConnected
|
|
}
|
|
|
|
return .offline
|
|
}
|
|
}
|