mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Remove message retention and /save command
- Delete MessageRetentionService.swift - Remove retentionEnabledChannels from ChatViewModel - Remove /save command handling - Remove retention UI elements from ContentView - Remove channelRetention message type from protocol - Update documentation and tests
This commit is contained in:
@@ -76,7 +76,6 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- `/clear` - Clear chat messages
|
||||
- `/pass [password]` - Set/change channel password (owner only)
|
||||
- `/transfer @name` - Transfer channel ownership
|
||||
- `/save` - Toggle message retention for channel (owner only)
|
||||
|
||||
### Getting Started
|
||||
|
||||
@@ -89,7 +88,6 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
### Channel Features
|
||||
|
||||
- **Password Protection**: Channel owners can set passwords with `/pass`
|
||||
- **Message Retention**: Owners can enable mandatory message saving with `/save`
|
||||
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
|
||||
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
|
||||
|
||||
|
||||
@@ -72,9 +72,7 @@
|
||||
24F17B1446E13F42652B7B08 /* PasswordProtectedChannelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036A1A705AAF9EC21F4354BE /* PasswordProtectedChannelTests.swift */; };
|
||||
2E71E320EA921498C57E023B /* BitchatMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA8FF26ABDC1C642A8C7AE5 /* BitchatMessageTests.swift */; };
|
||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
|
||||
4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */; };
|
||||
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
|
||||
4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */; };
|
||||
5D95F2BFBE257A1225998389 /* BatteryOptimizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */; };
|
||||
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
|
||||
6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
|
||||
@@ -193,7 +191,6 @@
|
||||
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = bitchat.entitlements; sourceTree = "<group>"; };
|
||||
53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
|
||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.extensionkit-extension"; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = "<group>"; };
|
||||
6E2446380E7A44E49A35B664 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
|
||||
8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = "<group>"; };
|
||||
@@ -367,7 +364,6 @@
|
||||
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
|
||||
12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */,
|
||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */,
|
||||
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */,
|
||||
AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */,
|
||||
3448F84BF86A42A3CC4A9379 /* NotificationService.swift */,
|
||||
);
|
||||
@@ -580,7 +576,6 @@
|
||||
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
|
||||
04B6BA572E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||
04B6BA582E203D6C0090FE39 /* NoiseTestingView.swift in Sources */,
|
||||
4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */,
|
||||
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */,
|
||||
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
|
||||
0245710AEAA58AD0A1425234 /* OptimizedBloomFilter.swift in Sources */,
|
||||
@@ -617,7 +612,6 @@
|
||||
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
|
||||
04B6BA552E203D6C0090FE39 /* FingerprintView.swift in Sources */,
|
||||
04B6BA562E203D6C0090FE39 /* NoiseTestingView.swift in Sources */,
|
||||
4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */,
|
||||
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */,
|
||||
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
|
||||
1F48A8CEEE9399D1EBD08F0C /* OptimizedBloomFilter.swift in Sources */,
|
||||
|
||||
@@ -84,7 +84,6 @@ enum MessageType: UInt8 {
|
||||
case fragmentContinue = 0x06
|
||||
case fragmentEnd = 0x07
|
||||
case channelAnnounce = 0x08 // Announce password-protected channel status
|
||||
case channelRetention = 0x09 // Announce channel retention status
|
||||
case deliveryAck = 0x0A // Acknowledge message received
|
||||
case deliveryStatusRequest = 0x0B // Request delivery status update
|
||||
case readReceipt = 0x0C // Message has been read/viewed
|
||||
@@ -112,7 +111,6 @@ enum MessageType: UInt8 {
|
||||
case .fragmentContinue: return "fragmentContinue"
|
||||
case .fragmentEnd: return "fragmentEnd"
|
||||
case .channelAnnounce: return "channelAnnounce"
|
||||
case .channelRetention: return "channelRetention"
|
||||
case .deliveryAck: return "deliveryAck"
|
||||
case .deliveryStatusRequest: return "deliveryStatusRequest"
|
||||
case .readReceipt: return "readReceipt"
|
||||
|
||||
@@ -1104,27 +1104,6 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Payload format: channel|enabled|creatorID
|
||||
let enabledFlag = enabled ? "1" : "0"
|
||||
let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.channelRetention.rawValue,
|
||||
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
||||
recipientID: SpecialRecipients.broadcast,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(payload.utf8),
|
||||
signature: nil,
|
||||
ttl: 5 // Allow wider propagation for channel announcements
|
||||
)
|
||||
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
@@ -2282,29 +2261,6 @@ class BluetoothMeshService: NSObject {
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
|
||||
case .channelRetention:
|
||||
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
|
||||
// Parse payload: channel|enabled|creatorID
|
||||
let components = payloadStr.split(separator: "|").map(String.init)
|
||||
if components.count >= 3 {
|
||||
let channel = components[0]
|
||||
let enabled = components[1] == "1"
|
||||
let creatorID = components[2]
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
|
||||
}
|
||||
|
||||
// Relay announcement
|
||||
if packet.ttl > 1 {
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl -= 1
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .readReceipt:
|
||||
// Handle read receipt
|
||||
if let recipientIDData = packet.recipientID,
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
//
|
||||
// MessageRetentionService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct StoredMessage: Codable {
|
||||
let id: String
|
||||
let sender: String
|
||||
let senderPeerID: String?
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
let channelTag: String?
|
||||
let isPrivate: Bool
|
||||
let recipientPeerID: String?
|
||||
}
|
||||
|
||||
class MessageRetentionService {
|
||||
static let shared = MessageRetentionService()
|
||||
|
||||
private let documentsDirectory: URL
|
||||
private let messagesDirectory: URL
|
||||
private let favoriteChannelsKey = "bitchat.favoriteChannels"
|
||||
private let retentionDays = 7 // Messages retained for 7 days
|
||||
private let encryptionKey: SymmetricKey
|
||||
|
||||
private init() {
|
||||
// Get documents directory with fallback to temp directory
|
||||
if let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
documentsDirectory = docsDir
|
||||
} else {
|
||||
// Fallback to temporary directory if documents directory is not accessible
|
||||
documentsDirectory = FileManager.default.temporaryDirectory
|
||||
SecurityLogger.log("Using temporary directory for message retention",
|
||||
category: SecurityLogger.security, level: .warning)
|
||||
}
|
||||
messagesDirectory = documentsDirectory.appendingPathComponent("Messages", isDirectory: true)
|
||||
|
||||
// Create messages directory if it doesn't exist
|
||||
try? FileManager.default.createDirectory(at: messagesDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Generate or retrieve encryption key from keychain
|
||||
let loadedKey: SymmetricKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey") {
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
_ = KeychainManager.shared.saveIdentityKey(keyData, forKey: "messageRetentionKey")
|
||||
}
|
||||
|
||||
// Now assign the final value
|
||||
self.encryptionKey = loadedKey
|
||||
|
||||
// Clean up old messages on init
|
||||
cleanupOldMessages()
|
||||
}
|
||||
|
||||
// MARK: - Favorite Channels Management
|
||||
|
||||
func getFavoriteChannels() -> Set<String> {
|
||||
let channels = UserDefaults.standard.stringArray(forKey: favoriteChannelsKey) ?? []
|
||||
return Set(channels)
|
||||
}
|
||||
|
||||
func toggleFavoriteChannel(_ channel: String) -> Bool {
|
||||
var favorites = getFavoriteChannels()
|
||||
if favorites.contains(channel) {
|
||||
favorites.remove(channel)
|
||||
// Clean up messages for this channel
|
||||
deleteMessagesForChannel(channel)
|
||||
} else {
|
||||
favorites.insert(channel)
|
||||
}
|
||||
UserDefaults.standard.set(Array(favorites), forKey: favoriteChannelsKey)
|
||||
return favorites.contains(channel)
|
||||
}
|
||||
|
||||
// MARK: - Message Storage
|
||||
|
||||
func saveMessage(_ message: BitchatMessage, forChannel channel: String?) {
|
||||
// Only save messages for favorite channels
|
||||
guard let channel = channel ?? message.channel,
|
||||
getFavoriteChannels().contains(channel) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to StoredMessage
|
||||
let storedMessage = StoredMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
senderPeerID: message.senderPeerID,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
channelTag: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.senderPeerID
|
||||
)
|
||||
|
||||
// Encode message
|
||||
guard let messageData = try? JSONEncoder().encode(storedMessage) else { return }
|
||||
|
||||
// Encrypt message
|
||||
guard let encryptedData = encrypt(messageData) else { return }
|
||||
|
||||
// Save to file
|
||||
let fileName = "\(channel)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
|
||||
let fileURL = messagesDirectory.appendingPathComponent(fileName)
|
||||
|
||||
try? encryptedData.write(to: fileURL)
|
||||
}
|
||||
|
||||
func loadMessagesForChannel(_ channel: String) -> [BitchatMessage] {
|
||||
guard getFavoriteChannels().contains(channel) else { return [] }
|
||||
|
||||
var messages: [BitchatMessage] = []
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
|
||||
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
|
||||
|
||||
for fileURL in channelFiles {
|
||||
if let encryptedData = try? Data(contentsOf: fileURL),
|
||||
let decryptedData = decrypt(encryptedData),
|
||||
let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) {
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: storedMessage.sender,
|
||||
content: storedMessage.content,
|
||||
timestamp: storedMessage.timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: storedMessage.isPrivate,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: storedMessage.senderPeerID,
|
||||
mentions: nil,
|
||||
channel: storedMessage.channelTag
|
||||
)
|
||||
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return messages.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
// MARK: - Encryption
|
||||
|
||||
private func encrypt(_ data: Data) -> Data? {
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
return sealedBox.combined
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func decrypt(_ data: Data) -> Data? {
|
||||
do {
|
||||
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||
return try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanupOldMessages() {
|
||||
let cutoffDate = Date().addingTimeInterval(-TimeInterval(retentionDays * 24 * 60 * 60))
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: [.creationDateKey])
|
||||
|
||||
for fileURL in files {
|
||||
if let attributes = try? fileURL.resourceValues(forKeys: [.creationDateKey]),
|
||||
let creationDate = attributes.creationDate,
|
||||
creationDate < cutoffDate {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessagesForChannel(_ channel: String) {
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
|
||||
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
|
||||
|
||||
for fileURL in channelFiles {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAllStoredMessages() {
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
|
||||
for fileURL in files {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
// Clear favorite channels
|
||||
UserDefaults.standard.removeObject(forKey: favoriteChannelsKey)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,6 @@ class ChatViewModel: ObservableObject {
|
||||
@Published var channelMetadata: [String: ChannelMetadata] = [:] // channel -> metadata from creator
|
||||
@Published var showPasswordPrompt: Bool = false
|
||||
@Published var passwordPromptChannel: String? = nil
|
||||
@Published var savedChannels: Set<String> = [] // Channels saved for message retention
|
||||
@Published var retentionEnabledChannels: Set<String> = [] // Channels where owner enabled retention for all members
|
||||
@Published var channelVerificationStatus: [String: ChannelVerificationStatus] = [:] // Track verification status
|
||||
|
||||
var meshService = BluetoothMeshService()
|
||||
@@ -69,7 +67,6 @@ class ChatViewModel: ObservableObject {
|
||||
private let channelCreatorsKey = "bitchat.channelCreators"
|
||||
// private let channelPasswordsKey = "bitchat.channelPasswords" // Now using Keychain
|
||||
private let channelKeyCommitmentsKey = "bitchat.channelKeyCommitments"
|
||||
private let retentionEnabledChannelsKey = "bitchat.retentionEnabledChannels"
|
||||
private var nicknameSaveTimer: Timer?
|
||||
|
||||
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
|
||||
@@ -96,8 +93,6 @@ class ChatViewModel: ObservableObject {
|
||||
loadChannelData()
|
||||
loadBlockedUsers()
|
||||
loadVerifiedFingerprints()
|
||||
// Load saved channels state
|
||||
savedChannels = MessageRetentionService.shared.getFavoriteChannels()
|
||||
meshService.delegate = self
|
||||
|
||||
// Log startup info
|
||||
@@ -252,14 +247,6 @@ class ChatViewModel: ObservableObject {
|
||||
if channelMembers[channel] == nil {
|
||||
channelMembers[channel] = Set()
|
||||
}
|
||||
|
||||
// Load saved messages if this channel has retention enabled
|
||||
if retentionEnabledChannels.contains(channel) {
|
||||
let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channel)
|
||||
if !savedMessages.isEmpty {
|
||||
channelMessages[channel] = savedMessages
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,11 +278,6 @@ class ChatViewModel: ObservableObject {
|
||||
channelKeyCommitments = savedCommitments
|
||||
}
|
||||
|
||||
// Load retention-enabled channels
|
||||
if let savedRetentionChannels = userDefaults.stringArray(forKey: retentionEnabledChannelsKey) {
|
||||
retentionEnabledChannels = Set(savedRetentionChannels)
|
||||
}
|
||||
|
||||
// Load channel passwords from Keychain
|
||||
let savedPasswords = KeychainManager.shared.getAllChannelPasswords()
|
||||
channelPasswords = savedPasswords
|
||||
@@ -322,7 +304,6 @@ class ChatViewModel: ObservableObject {
|
||||
_ = KeychainManager.shared.saveChannelPassword(password, for: channel)
|
||||
}
|
||||
userDefaults.set(channelKeyCommitments, forKey: channelKeyCommitmentsKey)
|
||||
userDefaults.set(Array(retentionEnabledChannels), forKey: retentionEnabledChannelsKey)
|
||||
|
||||
// Force synchronize and add a small delay to ensure writes complete
|
||||
_ = userDefaults.synchronize()
|
||||
@@ -576,23 +557,6 @@ class ChatViewModel: ObservableObject {
|
||||
channelMessages[channelTag] = []
|
||||
}
|
||||
|
||||
// Load saved messages if this is a favorite channel
|
||||
if MessageRetentionService.shared.getFavoriteChannels().contains(channelTag) {
|
||||
let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channelTag)
|
||||
if !savedMessages.isEmpty {
|
||||
// Merge saved messages with current messages, avoiding duplicates
|
||||
var existingMessageIDs = Set(channelMessages[channelTag]?.map { $0.id } ?? [])
|
||||
for savedMessage in savedMessages {
|
||||
if !existingMessageIDs.contains(savedMessage.id) {
|
||||
channelMessages[channelTag]?.append(savedMessage)
|
||||
existingMessageIDs.insert(savedMessage.id)
|
||||
}
|
||||
}
|
||||
// Sort by timestamp
|
||||
channelMessages[channelTag]?.sort { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
// Hide password prompt if it was showing
|
||||
showPasswordPrompt = false
|
||||
passwordPromptChannel = nil
|
||||
@@ -1335,11 +1299,6 @@ class ChatViewModel: ObservableObject {
|
||||
}
|
||||
channelMessages[channel]?.append(message)
|
||||
|
||||
// Save message if channel has retention enabled
|
||||
if retentionEnabledChannels.contains(channel) {
|
||||
MessageRetentionService.shared.saveMessage(message, forChannel: channel)
|
||||
}
|
||||
|
||||
// Track ourselves as a channel member
|
||||
if channelMembers[channel] == nil {
|
||||
channelMembers[channel] = Set()
|
||||
@@ -1747,11 +1706,6 @@ class ChatViewModel: ObservableObject {
|
||||
verifiedFingerprints.removeAll()
|
||||
// Verified fingerprints are cleared when identity data is cleared below
|
||||
|
||||
// Clear all retained messages
|
||||
MessageRetentionService.shared.deleteAllStoredMessages()
|
||||
savedChannels.removeAll()
|
||||
retentionEnabledChannels.removeAll()
|
||||
|
||||
// Clear message retry queue
|
||||
MessageRetryService.shared.clearRetryQueue()
|
||||
|
||||
@@ -1760,7 +1714,6 @@ class ChatViewModel: ObservableObject {
|
||||
userDefaults.removeObject(forKey: passwordProtectedChannelsKey)
|
||||
userDefaults.removeObject(forKey: channelCreatorsKey)
|
||||
userDefaults.removeObject(forKey: channelKeyCommitmentsKey)
|
||||
userDefaults.removeObject(forKey: retentionEnabledChannelsKey)
|
||||
|
||||
// Reset nickname to anonymous
|
||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||
@@ -2478,69 +2431,7 @@ extension ChatViewModel: BitchatDelegate {
|
||||
}
|
||||
|
||||
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
|
||||
|
||||
// Only process if we're a member of this channel
|
||||
guard joinedChannels.contains(channel) else { return }
|
||||
|
||||
// Verify the announcement is from the channel owner
|
||||
if let creatorID = creatorID, channelCreators[channel] != creatorID {
|
||||
return
|
||||
}
|
||||
|
||||
// Update retention status
|
||||
if enabled {
|
||||
retentionEnabledChannels.insert(channel)
|
||||
savedChannels.insert(channel)
|
||||
// Ensure channel is in favorites if not already
|
||||
if !MessageRetentionService.shared.getFavoriteChannels().contains(channel) {
|
||||
_ = MessageRetentionService.shared.toggleFavoriteChannel(channel)
|
||||
}
|
||||
|
||||
// Show system message
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "channel owner enabled message retention for \(channel). all messages will be saved locally.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
if currentChannel == channel {
|
||||
messages.append(systemMessage)
|
||||
} else if var channelMsgs = channelMessages[channel] {
|
||||
channelMsgs.append(systemMessage)
|
||||
channelMessages[channel] = channelMsgs
|
||||
} else {
|
||||
channelMessages[channel] = [systemMessage]
|
||||
}
|
||||
} else {
|
||||
retentionEnabledChannels.remove(channel)
|
||||
savedChannels.remove(channel)
|
||||
|
||||
// Delete all saved messages for this channel
|
||||
MessageRetentionService.shared.deleteMessagesForChannel(channel)
|
||||
// Remove from favorites if currently set
|
||||
if MessageRetentionService.shared.getFavoriteChannels().contains(channel) {
|
||||
_ = MessageRetentionService.shared.toggleFavoriteChannel(channel)
|
||||
}
|
||||
|
||||
// Show system message
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "channel owner disabled message retention for \(channel). all saved messages have been deleted.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
if currentChannel == channel {
|
||||
messages.append(systemMessage)
|
||||
} else if var channelMsgs = channelMessages[channel] {
|
||||
channelMsgs.append(systemMessage)
|
||||
channelMessages[channel] = channelMsgs
|
||||
} else {
|
||||
channelMessages[channel] = [systemMessage]
|
||||
}
|
||||
}
|
||||
|
||||
// Persist retention status
|
||||
userDefaults.set(Array(retentionEnabledChannels), forKey: retentionEnabledChannelsKey)
|
||||
// This feature has been removed
|
||||
}
|
||||
|
||||
private func handleCommand(_ command: String) {
|
||||
@@ -2698,9 +2589,6 @@ extension ChatViewModel: BitchatDelegate {
|
||||
if passwordProtectedChannels.contains(channel) {
|
||||
status += " 🔒"
|
||||
}
|
||||
if retentionEnabledChannels.contains(channel) {
|
||||
status += " 📌"
|
||||
}
|
||||
let myFingerprint = getMyFingerprint()
|
||||
if channelCreators[channel] == meshService.myPeerID || channelCreators[channel] == myFingerprint {
|
||||
status += " (owner)"
|
||||
@@ -2789,92 +2677,6 @@ extension ChatViewModel: BitchatDelegate {
|
||||
// Clear main messages
|
||||
messages.removeAll()
|
||||
}
|
||||
case "/save":
|
||||
// Toggle retention for current channel (owner only)
|
||||
guard let channel = currentChannel else {
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "you must be in a channel to toggle message retention.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
messages.append(systemMessage)
|
||||
break
|
||||
}
|
||||
|
||||
// Check if user is the channel owner
|
||||
let myFingerprint = getMyFingerprint()
|
||||
guard channelCreators[channel] == meshService.myPeerID || channelCreators[channel] == myFingerprint else {
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "only the channel owner can toggle message retention.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
messages.append(systemMessage)
|
||||
break
|
||||
}
|
||||
|
||||
// Toggle retention status
|
||||
let isEnabling = !retentionEnabledChannels.contains(channel)
|
||||
|
||||
if isEnabling {
|
||||
// Enable retention for this channel
|
||||
retentionEnabledChannels.insert(channel)
|
||||
savedChannels.insert(channel)
|
||||
_ = MessageRetentionService.shared.toggleFavoriteChannel(channel) // Enable if not already
|
||||
|
||||
// Announce to all members that retention is enabled
|
||||
meshService.sendChannelRetentionAnnouncement(channel, enabled: true)
|
||||
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "message retention enabled for channel \(channel). all members will save messages locally.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
messages.append(systemMessage)
|
||||
|
||||
// Load any previously saved messages
|
||||
let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channel)
|
||||
if !savedMessages.isEmpty {
|
||||
// Merge saved messages with current messages, avoiding duplicates
|
||||
var existingMessageIDs = Set(channelMessages[channel]?.map { $0.id } ?? [])
|
||||
for savedMessage in savedMessages {
|
||||
if !existingMessageIDs.contains(savedMessage.id) {
|
||||
if channelMessages[channel] == nil {
|
||||
channelMessages[channel] = []
|
||||
}
|
||||
channelMessages[channel]?.append(savedMessage)
|
||||
existingMessageIDs.insert(savedMessage.id)
|
||||
}
|
||||
}
|
||||
// Sort by timestamp
|
||||
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
} else {
|
||||
// Disable retention for this channel
|
||||
retentionEnabledChannels.remove(channel)
|
||||
savedChannels.remove(channel)
|
||||
|
||||
// Delete all saved messages for this channel
|
||||
MessageRetentionService.shared.deleteMessagesForChannel(channel)
|
||||
_ = MessageRetentionService.shared.toggleFavoriteChannel(channel) // Disable if enabled
|
||||
|
||||
// Announce to all members that retention is disabled
|
||||
meshService.sendChannelRetentionAnnouncement(channel, enabled: false)
|
||||
|
||||
let systemMessage = BitchatMessage(
|
||||
sender: "system",
|
||||
content: "message retention disabled for channel \(channel). all saved messages will be deleted on all devices.",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
messages.append(systemMessage)
|
||||
}
|
||||
|
||||
// Save the updated channel data
|
||||
saveChannelData()
|
||||
case "/debug":
|
||||
// Debug command to check ownership info
|
||||
if let channel = currentChannel {
|
||||
@@ -3541,11 +3343,6 @@ extension ChatViewModel: BitchatDelegate {
|
||||
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
// Save message if channel has retention enabled
|
||||
if retentionEnabledChannels.contains(channel) {
|
||||
MessageRetentionService.shared.saveMessage(messageToAdd, forChannel: channel)
|
||||
}
|
||||
|
||||
// Track channel members - only track the sender as a member
|
||||
if channelMembers[channel] == nil {
|
||||
channelMembers[channel] = Set()
|
||||
|
||||
@@ -380,7 +380,6 @@ struct ContentView: View {
|
||||
|
||||
let channelCommandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/pass"], "[password]", "change channel password"),
|
||||
(["/save"], nil, "save channel messages locally"),
|
||||
(["/transfer"], "<nickname>", "transfer channel ownership")
|
||||
]
|
||||
|
||||
@@ -490,7 +489,6 @@ struct ContentView: View {
|
||||
// Add channel-specific commands if in a channel
|
||||
if viewModel.currentChannel != nil {
|
||||
commandDescriptions.append(("/pass", "change channel password"))
|
||||
commandDescriptions.append(("/save", "save channel messages locally"))
|
||||
commandDescriptions.append(("/transfer", "transfer channel ownership"))
|
||||
}
|
||||
|
||||
@@ -1192,29 +1190,6 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
// Show retention indicator for all users
|
||||
if viewModel.retentionEnabledChannels.contains(currentChannel) {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Color.yellow)
|
||||
.help("Messages in this channel are being saved locally")
|
||||
.accessibilityLabel("Message retention enabled")
|
||||
}
|
||||
|
||||
// Save button - only for channel owner
|
||||
if viewModel.isChannelOwner(currentChannel) {
|
||||
Button(action: {
|
||||
viewModel.sendMessage("/save")
|
||||
}) {
|
||||
Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
||||
.accessibilityLabel(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
||||
}
|
||||
|
||||
// Password button for channel creator only
|
||||
if viewModel.isChannelOwner(currentChannel) {
|
||||
Button(action: {
|
||||
|
||||
@@ -97,12 +97,10 @@ class KeychainIntegrationTests: XCTestCase {
|
||||
func testNoPlaintextInUserDefaults() {
|
||||
// Create services to generate keys
|
||||
_ = NoiseEncryptionService()
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Check UserDefaults for any sensitive data
|
||||
let keysToCheck = [
|
||||
"bitchat.noiseIdentityKey",
|
||||
"bitchat.messageRetentionKey",
|
||||
"bitchat.channelPasswords",
|
||||
"bitchat.identityKey",
|
||||
"bitchat.staticKey"
|
||||
|
||||
@@ -112,31 +112,7 @@ class NoiseIdentityPersistenceTests: XCTestCase {
|
||||
}
|
||||
|
||||
// MARK: - Message Retention Key Tests
|
||||
|
||||
func testMessageRetentionKeyPersistence() {
|
||||
// Create first instance
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Get key from keychain
|
||||
let keyData1 = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey")
|
||||
XCTAssertNotNil(keyData1, "Message retention key should be stored")
|
||||
|
||||
// Simulate app restart by clearing the singleton
|
||||
// (In real app, this would be a new process)
|
||||
|
||||
// Get key again
|
||||
let keyData2 = KeychainManager.shared.getIdentityKey(forKey: "messageRetentionKey")
|
||||
XCTAssertEqual(keyData1, keyData2, "Message retention key should persist")
|
||||
}
|
||||
|
||||
func testMessageRetentionKeyNotInUserDefaults() {
|
||||
// Ensure service is initialized
|
||||
_ = MessageRetentionService.shared
|
||||
|
||||
// Verify key is NOT in UserDefaults
|
||||
let userDefaultsData = UserDefaults.standard.data(forKey: "bitchat.messageRetentionKey")
|
||||
XCTAssertNil(userDefaultsData, "Message retention key should NOT be in UserDefaults")
|
||||
}
|
||||
// Message retention feature has been removed
|
||||
|
||||
// MARK: - Keychain Service Name Tests
|
||||
|
||||
|
||||
Reference in New Issue
Block a user