Files
bitchat/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift
T
jackandClaude Fable 5 7ad19ab4c3 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>
2026-07-06 20:22:38 +02:00

208 lines
6.7 KiB
Swift

//
// ChatViewModel+PrivateChat.swift
// bitchat
//
// Private chat and media transfer logic for ChatViewModel
//
import BitFoundation
import Foundation
import SwiftUI
extension ChatViewModel {
@MainActor
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
// Group chats reuse the private-chat surface but broadcast a sealed
// envelope instead of routing to a single peer.
if peerID.isGroup {
groupCoordinator.sendGroupMessage(content, to: peerID)
return
}
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
}
@MainActor
func sendGeohashDM(_ content: String, to peerID: PeerID) {
privateConversationCoordinator.sendGeohashDM(content, to: peerID)
}
@MainActor
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
) {
privateConversationCoordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
}
@MainActor
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
privateConversationCoordinator.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
}
@MainActor
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
privateConversationCoordinator.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
}
@MainActor
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
privateConversationCoordinator.sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id)
}
@MainActor
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
privateConversationCoordinator.sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id)
}
@MainActor
func sendVoiceNote(at url: URL) {
mediaTransferCoordinator.sendVoiceNote(at: url)
}
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
mediaTransferCoordinator.processThenSendImage(image)
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
mediaTransferCoordinator.processThenSendImage(from: url)
}
#endif
@MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
mediaTransferCoordinator.sendImage(from: sourceURL, cleanup: cleanup)
}
@MainActor
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
mediaTransferCoordinator.enqueueMediaMessage(content: content, targetPeer: targetPeer)
}
@MainActor
func registerTransfer(transferId: String, messageID: String) {
mediaTransferCoordinator.registerTransfer(transferId: transferId, messageID: messageID)
}
func makeTransferID(messageID: String) -> String {
mediaTransferCoordinator.makeTransferID(messageID: messageID)
}
@MainActor
func clearTransferMapping(for messageID: String) {
mediaTransferCoordinator.clearTransferMapping(for: messageID)
}
@MainActor
func handleMediaSendFailure(messageID: String, reason: String) {
mediaTransferCoordinator.handleMediaSendFailure(messageID: messageID, reason: reason)
}
@MainActor
func handleTransferEvent(_ event: TransferProgressManager.Event) {
mediaTransferCoordinator.handleTransferEvent(event)
}
func cleanupLocalFile(forMessage message: BitchatMessage) {
mediaTransferCoordinator.cleanupLocalFile(forMessage: message)
}
@MainActor
func cancelMediaSend(messageID: String) {
mediaTransferCoordinator.cancelMediaSend(messageID: messageID)
}
@MainActor
func deleteMediaMessage(messageID: String) {
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
}
@MainActor
func handlePrivateMessage(_ message: BitchatMessage) {
privateConversationCoordinator.handlePrivateMessage(message)
}
@MainActor
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
privateConversationCoordinator.isDuplicateMessage(messageId, targetPeerID: targetPeerID)
}
@MainActor
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
privateConversationCoordinator.addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
}
@MainActor
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
privateConversationCoordinator.mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: key)
}
@MainActor
func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) {
privateConversationCoordinator.handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: key,
senderPubkey: senderPubkey
)
}
@MainActor
func markAsUnreadIfNeeded(
shouldMarkAsUnread: Bool,
targetPeerID: PeerID,
key: Data?,
isRecentMessage: Bool,
senderNickname: String,
messageContent: String
) {
privateConversationCoordinator.markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: key,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
@MainActor
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.handleFavoriteNotification(
content,
from: peerID,
senderNickname: senderNickname
)
}
@MainActor
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
privateConversationCoordinator.processActionMessage(message)
}
@MainActor
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.migratePrivateChatsIfNeeded(for: peerID, senderNickname: senderNickname)
}
@MainActor
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
privateConversationCoordinator.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
@MainActor
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
privateConversationCoordinator.isMessageBlocked(message)
}
}