Files
bitchat/bitchat/ViewModels/ChatLifecycleCoordinator.swift
T
jackandClaude Fable 5 ccccb2dd8c 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>
2026-07-06 21:17:15 +02:00

372 lines
14 KiB
Swift

import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatLifecycleCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Clears the peer's unread flag (store unread state only).
func markPrivateChatRead(_ peerID: PeerID)
var sentReadReceipts: Set<String> { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded — the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// The owner-level read pass (chat manager + receipts); used for the
/// delayed re-run after the app becomes active.
func markPrivateMessagesAsRead(from peerID: PeerID)
/// Marks the chat read in the private chat manager (sends pending mesh READ acks).
func markChatAsRead(from peerID: PeerID)
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func addSystemMessage(_ content: String)
// MARK: Peers & sessions
func peerNickname(for peerID: PeerID) -> String?
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func stopMeshServices()
/// Re-reads the transport's current Bluetooth state and updates the alert UI.
func refreshBluetoothState()
// MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Nostr & geohash
var isTeleported: Bool { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func recordGeoParticipant(pubkeyHex: String)
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
// MARK: Identity persistence
/// Forces the identity manager to persist its state now.
func forceSaveIdentity()
/// Confirms the Noise identity key is still present in the keychain.
@discardableResult
func verifyIdentityKeyExists() -> Bool
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
// and `favoriteRelationship(forNoiseKey:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func markChatAsRead(from peerID: PeerID) {
privateChatManager.markAsRead(from: peerID)
}
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task { @MainActor in
work()
}
}
}
func stopMeshServices() {
meshService.stopServices()
}
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
}
}
func forceSaveIdentity() {
identityManager.forceSave()
}
@discardableResult
func verifyIdentityKeyExists() -> Bool {
keychain.verifyIdentityKeyExists()
}
}
@MainActor
final class ChatLifecycleCoordinator {
private unowned let context: any ChatLifecycleContext
init(context: any ChatLifecycleContext) {
self.context = context
}
func handleDidBecomeActive() {
context.refreshBluetoothState()
guard let peerID = context.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let context = self.context
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
context?.markPrivateMessagesAsRead(from: peerID)
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(context.nickname) took a screenshot *"
if let peerID = context.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
)
appendPrivateScreenshotNotice(for: peerID)
return
}
switch context.activeChannel {
case .mesh:
context.sendMeshMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date()
)
case .location(let channel):
sendPublicGeohashScreenshotMessage(
screenshotMessage,
channel: channel
)
}
context.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
context.forceSaveIdentity()
context.verifyIdentityKeyExists()
}
func applicationWillTerminate() {
context.stopMeshServices()
saveIdentityState()
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID)
// Group chats are keyed under a virtual group_ peerID; no member IS the
// conversation peer, so the receipt loops below (which gate on
// senderPeerID == peerID) must never emit a read/delivered receipt for
// one. This guard makes that explicit so a future refactor of the
// receipt matching can't silently start leaking receipts into groups.
guard !peerID.isGroup else { return }
if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateMessages(for: peerID)
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !context.sentReadReceipts.contains(message.id) else { continue }
SecureLogger.debug(
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…",
category: .session
)
context.sendGeohashReadReceipt(
message.id,
toRecipientHex: recipientHex,
from: identity
)
context.markReadReceiptSent(message.id)
}
return
}
var noiseKeyHex: PeerID?
var peerNostrPubkey: String?
if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} else if let peer = context.unifiedPeer(for: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
context.markPrivateChatRead(noiseKeyHex)
}
}
guard peerNostrPubkey != nil else { return }
for message in getPrivateChatMessages(for: peerID) {
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
continue
}
guard !context.sentReadReceipts.contains(message.id) else { continue }
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: context.myPeerID,
readerNickname: context.nickname
)
let recipientPeerID = peerID.isHex
? peerID
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
context.routeReadReceipt(receipt, to: recipientPeerID)
context.markReadReceiptSent(message.id)
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return context.messages }
return getPrivateChatMessages(for: peerID)
}
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
combined.append(contentsOf: context.privateMessages(for: peerID))
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID {
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
}
}
var bestByID: [String: BitchatMessage] = [:]
for message in combined {
if let existing = bestByID[message.id] {
let existingRank = deliveryStatusRank(existing.deliveryStatus)
let candidateRank = deliveryStatusRank(message.deliveryStatus)
if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) {
bestByID[message.id] = message
}
} else {
bestByID[message.id] = message
}
}
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
}
}
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = context.peerNickname(for: peerID) else { return }
let sessionState = context.noiseSessionState(for: peerID)
switch sessionState {
case .established:
context.routePrivateMessage(
message,
to: peerID,
recipientNickname: peerNickname,
messageID: UUID().uuidString
)
case .none, .failed, .handshakeQueued, .handshaking:
SecureLogger.debug(
"Skipping screenshot notification to \(peerID) - no established session",
category: .security
)
}
}
func appendPrivateScreenshotNotice(for peerID: PeerID) {
let notice = BitchatMessage(
sender: "system",
content: "you took a screenshot",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: context.peerNickname(for: peerID),
senderPeerID: context.myPeerID
)
context.appendPrivateMessage(notice, to: peerID)
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
}
}
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
guard let status else { return 0 }
switch status {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .carried: return 4
case .partiallyDelivered: return 5
case .delivered: return 6
case .read: return 7
}
}
}