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:
jack
2025-07-16 15:59:38 +02:00
parent fcc0735928
commit b61904bee9
9 changed files with 2 additions and 532 deletions
-2
View File
@@ -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`
-6
View File
@@ -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 */,
-2
View File
@@ -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)
}
}
+1 -204
View File
@@ -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()
-25
View File
@@ -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