Add room-wide mandatory retention, update UI formatting, and documentation

- Implement room-wide message retention controlled by room owners
- Change username format from <name> to <@name> throughout UI
- Fix text alignment in chat messages (consistent font sizes)
- Add comprehensive technical whitepaper with Mermaid diagrams
- Update README with current features and commands
- Add retention status indicators and announcements
- Update command help text to use short versions (/j, /m)
This commit is contained in:
jack
2025-07-05 19:35:37 +02:00
parent 81f0022dbc
commit 1f890b00ac
6 changed files with 1026 additions and 77 deletions
+6
View File
@@ -73,6 +73,7 @@ enum MessageType: UInt8 {
case fragmentContinue = 0x06
case fragmentEnd = 0x07
case roomAnnounce = 0x08 // Announce password-protected room status
case roomRetention = 0x09 // Announce room retention status
}
// Special recipient ID for broadcast messages
@@ -165,6 +166,7 @@ protocol BitchatDelegate: AnyObject {
func didUpdatePeerList(_ peers: [String])
func didReceiveRoomLeave(_ room: String, from peerID: String)
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?)
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String?
// Optional method to check if a fingerprint belongs to a favorite peer
@@ -185,6 +187,10 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
// Default empty implementation
}
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? {
// Default returns nil (unable to decrypt)
return nil
@@ -710,6 +710,29 @@ class BluetoothMeshService: NSObject {
}
}
func sendRoomRetentionAnnouncement(_ room: String, enabled: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: room|enabled|creatorID
let enabledFlag = enabled ? "1" : "0"
let payload = "\(room)|\(enabledFlag)|\(self.myPeerID)"
let packet = BitchatPacket(
type: MessageType.roomRetention.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8),
signature: nil,
ttl: 5 // Allow wider propagation for room announcements
)
bitchatLog("Announcing room \(room) retention status: \(enabled)", category: "room")
self.broadcastPacket(packet)
}
}
func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -1835,6 +1858,30 @@ class BluetoothMeshService: NSObject {
}
}
case .roomRetention:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: room|enabled|creatorID
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let room = components[0]
let enabled = components[1] == "1"
let creatorID = components[2]
bitchatLog("Received room retention announcement: \(room) retention \(enabled ? "enabled" : "disabled") by \(creatorID)", category: "room")
DispatchQueue.main.async {
self.delegate?.didReceiveRoomRetentionAnnouncement(room, enabled: enabled, creatorID: creatorID)
}
// Relay announcement
if packet.ttl > 1 {
var relayPacket = packet
relayPacket.ttl -= 1
self.broadcastPacket(relayPacket)
}
}
}
default:
break
}
+138 -38
View File
@@ -49,6 +49,7 @@ class ChatViewModel: ObservableObject {
@Published var showPasswordPrompt: Bool = false
@Published var passwordPromptRoom: String? = nil
@Published var savedRooms: Set<String> = [] // Rooms saved for message retention
@Published var retentionEnabledRooms: Set<String> = [] // Rooms where owner enabled retention for all members
let meshService = BluetoothMeshService()
private let userDefaults = UserDefaults.standard
@@ -59,6 +60,7 @@ class ChatViewModel: ObservableObject {
private let roomCreatorsKey = "bitchat.roomCreators"
// private let roomPasswordsKey = "bitchat.roomPasswords" // Now using Keychain
private let roomKeyCommitmentsKey = "bitchat.roomKeyCommitments"
private let retentionEnabledRoomsKey = "bitchat.retentionEnabledRooms"
private var nicknameSaveTimer: Timer?
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
@@ -129,8 +131,8 @@ class ChatViewModel: ObservableObject {
roomMembers[room] = Set()
}
// Load saved messages if this is a saved room
if MessageRetentionService.shared.getFavoriteRooms().contains(room) {
// Load saved messages if this room has retention enabled
if retentionEnabledRooms.contains(room) {
let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(room)
if !savedMessages.isEmpty {
roomMessages[room] = savedMessages
@@ -162,6 +164,11 @@ class ChatViewModel: ObservableObject {
roomKeyCommitments = savedCommitments
}
// Load retention-enabled rooms
if let savedRetentionRooms = userDefaults.stringArray(forKey: retentionEnabledRoomsKey) {
retentionEnabledRooms = Set(savedRetentionRooms)
}
// Load room passwords from Keychain
let savedPasswords = KeychainManager.shared.getAllRoomPasswords()
roomPasswords = savedPasswords
@@ -179,6 +186,7 @@ class ChatViewModel: ObservableObject {
_ = KeychainManager.shared.saveRoomPassword(password, for: room)
}
userDefaults.set(roomKeyCommitments, forKey: roomKeyCommitmentsKey)
userDefaults.set(Array(retentionEnabledRooms), forKey: retentionEnabledRoomsKey)
userDefaults.synchronize()
}
@@ -830,8 +838,8 @@ class ChatViewModel: ObservableObject {
}
roomMessages[room]?.append(message)
// Save message if room is a favorite
if MessageRetentionService.shared.getFavoriteRooms().contains(room) {
// Save message if room has retention enabled
if retentionEnabledRooms.contains(room) {
MessageRetentionService.shared.saveMessage(message, forRoom: room)
}
@@ -943,6 +951,7 @@ class ChatViewModel: ObservableObject {
// Clear all retained messages
MessageRetentionService.shared.deleteAllStoredMessages()
savedRooms.removeAll()
retentionEnabledRooms.removeAll()
// Clear message retry queue
MessageRetryService.shared.clearRetryQueue()
@@ -952,6 +961,7 @@ class ChatViewModel: ObservableObject {
userDefaults.removeObject(forKey: passwordProtectedRoomsKey)
userDefaults.removeObject(forKey: roomCreatorsKey)
userDefaults.removeObject(forKey: roomKeyCommitmentsKey)
userDefaults.removeObject(forKey: retentionEnabledRoomsKey)
// Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))"
@@ -1342,6 +1352,74 @@ extension ChatViewModel: BitchatDelegate {
}
}
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
bitchatLog("Received retention announcement for room \(room): \(enabled ? "enabled" : "disabled") by \(creatorID ?? "unknown")", category: "room")
// Only process if we're a member of this room
guard joinedRooms.contains(room) else { return }
// Verify the announcement is from the room owner
if let creatorID = creatorID, roomCreators[room] != creatorID {
bitchatLog("Ignoring retention announcement from non-owner \(creatorID) for room \(room)", category: "room")
return
}
// Update retention status
if enabled {
retentionEnabledRooms.insert(room)
savedRooms.insert(room)
// Ensure room is in favorites if not already
if !MessageRetentionService.shared.getFavoriteRooms().contains(room) {
_ = MessageRetentionService.shared.toggleFavoriteRoom(room)
}
// Show system message
let systemMessage = BitchatMessage(
sender: "system",
content: "room owner enabled message retention for \(room). all messages will be saved locally.",
timestamp: Date(),
isRelay: false
)
if currentRoom == room {
messages.append(systemMessage)
} else if var roomMsgs = roomMessages[room] {
roomMsgs.append(systemMessage)
roomMessages[room] = roomMsgs
} else {
roomMessages[room] = [systemMessage]
}
} else {
retentionEnabledRooms.remove(room)
savedRooms.remove(room)
// Delete all saved messages for this room
MessageRetentionService.shared.deleteMessagesForRoom(room)
// Remove from favorites if currently set
if MessageRetentionService.shared.getFavoriteRooms().contains(room) {
_ = MessageRetentionService.shared.toggleFavoriteRoom(room)
}
// Show system message
let systemMessage = BitchatMessage(
sender: "system",
content: "room owner disabled message retention for \(room). all saved messages have been deleted.",
timestamp: Date(),
isRelay: false
)
if currentRoom == room {
messages.append(systemMessage)
} else if var roomMsgs = roomMessages[room] {
roomMsgs.append(systemMessage)
roomMessages[room] = roomMsgs
} else {
roomMessages[room] = [systemMessage]
}
}
// Persist retention status
userDefaults.set(Array(retentionEnabledRooms), forKey: retentionEnabledRoomsKey)
}
private func handleCommand(_ command: String) {
let parts = command.split(separator: " ")
guard let cmd = parts.first else { return }
@@ -1402,7 +1480,7 @@ extension ChatViewModel: BitchatDelegate {
// Show usage hint
let systemMessage = BitchatMessage(
sender: "system",
content: "usage: /join #roomname",
content: "usage: /j #roomname",
timestamp: Date(),
isRelay: false
)
@@ -1452,7 +1530,7 @@ extension ChatViewModel: BitchatDelegate {
} else {
let systemMessage = BitchatMessage(
sender: "system",
content: "usage: /msg @nickname [message] or /msg nickname [message]",
content: "usage: /m @nickname [message] or /m nickname [message]",
timestamp: Date(),
isRelay: false
)
@@ -1497,6 +1575,9 @@ extension ChatViewModel: BitchatDelegate {
if passwordProtectedRooms.contains(room) {
status += " 🔒"
}
if retentionEnabledRooms.contains(room) {
status += " 📌"
}
if roomCreators[room] == meshService.myPeerID {
status += " (owner)"
}
@@ -1505,7 +1586,7 @@ extension ChatViewModel: BitchatDelegate {
let systemMessage = BitchatMessage(
sender: "system",
content: "discovered rooms:\n\(roomList)\n\n✓ = joined",
content: "discovered rooms:\n\(roomList)\n\n✓ = joined, 🔒 = password protected, 📌 = retention enabled",
timestamp: Date(),
isRelay: false
)
@@ -1588,11 +1669,11 @@ extension ChatViewModel: BitchatDelegate {
bitchatLog("cleared main chat", category: "chat")
}
case "/save":
// Toggle save status for current room
// Toggle retention for current room (owner only)
guard let room = currentRoom else {
let systemMessage = BitchatMessage(
sender: "system",
content: "you must be in a room to save it.",
content: "you must be in a room to toggle message retention.",
timestamp: Date(),
isRelay: false
)
@@ -1600,18 +1681,39 @@ extension ChatViewModel: BitchatDelegate {
break
}
let isFavorite = MessageRetentionService.shared.toggleFavoriteRoom(room)
let status = isFavorite ? "saved" : "unsaved"
// Update published property
if isFavorite {
savedRooms.insert(room)
} else {
savedRooms.remove(room)
// Check if user is the room owner
guard roomCreators[room] == meshService.myPeerID else {
let systemMessage = BitchatMessage(
sender: "system",
content: "only the room owner can toggle message retention.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
break
}
// If just marked as favorite, load any previously saved messages
if isFavorite {
// Toggle retention status
let isEnabling = !retentionEnabledRooms.contains(room)
if isEnabling {
// Enable retention for this room
retentionEnabledRooms.insert(room)
savedRooms.insert(room)
_ = MessageRetentionService.shared.toggleFavoriteRoom(room) // Enable if not already
// Announce to all members that retention is enabled
meshService.sendRoomRetentionAnnouncement(room, enabled: true)
let systemMessage = BitchatMessage(
sender: "system",
content: "message retention enabled for room \(room). all members will save messages locally.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
// Load any previously saved messages
let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(room)
if !savedMessages.isEmpty {
// Merge saved messages with current messages, avoiding duplicates
@@ -1627,32 +1729,30 @@ extension ChatViewModel: BitchatDelegate {
}
// Sort by timestamp
roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
let systemMessage = BitchatMessage(
sender: "system",
content: "room \(room) \(status). loaded \(savedMessages.count) saved messages.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
} else {
let systemMessage = BitchatMessage(
sender: "system",
content: "room \(room) \(status).",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
}
} else {
// Disable retention for this room
retentionEnabledRooms.remove(room)
savedRooms.remove(room)
// Delete all saved messages for this room
MessageRetentionService.shared.deleteMessagesForRoom(room)
_ = MessageRetentionService.shared.toggleFavoriteRoom(room) // Disable if enabled
// Announce to all members that retention is disabled
meshService.sendRoomRetentionAnnouncement(room, enabled: false)
let systemMessage = BitchatMessage(
sender: "system",
content: "room \(room) \(status).",
content: "message retention disabled for room \(room). all saved messages will be deleted on all devices.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
}
// Save the updated room data
saveRoomData()
default:
// Unknown command
let systemMessage = BitchatMessage(
@@ -1840,8 +1940,8 @@ extension ChatViewModel: BitchatDelegate {
roomMessages[room]?.append(messageToAdd)
roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
// Save message if room is a favorite
if MessageRetentionService.shared.getFavoriteRooms().contains(room) {
// Save message if room has retention enabled
if retentionEnabledRooms.contains(room) {
MessageRetentionService.shared.saveMessage(messageToAdd, forRoom: room)
}
+27 -16
View File
@@ -296,15 +296,26 @@ struct ContentView: View {
Spacer()
HStack(spacing: 8) {
// Save button
Button(action: {
viewModel.sendMessage("/save")
}) {
Image(systemName: viewModel.savedRooms.contains(currentRoom) ? "bookmark.fill" : "bookmark")
// Show retention indicator for all users
if viewModel.retentionEnabledRooms.contains(currentRoom) {
Image(systemName: "bookmark.fill")
.font(.system(size: 16))
.foregroundColor(viewModel.savedRooms.contains(currentRoom) ? Color.yellow : textColor)
.foregroundColor(Color.yellow)
.help("Messages in this room are being saved locally")
}
// Save button - only for room owner
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
Button(action: {
viewModel.sendMessage("/save")
}) {
Image(systemName: viewModel.retentionEnabledRooms.contains(currentRoom) ? "bookmark.slash" : "bookmark")
.font(.system(size: 16))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
.help(viewModel.retentionEnabledRooms.contains(currentRoom) ? "Disable message retention" : "Enable message retention")
}
.buttonStyle(.plain)
// Password button for room creator only
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
@@ -442,10 +453,10 @@ struct ContentView: View {
.textSelection(.enabled)
} else {
// Regular messages with tappable sender name
HStack(alignment: .top, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 0) {
// Timestamp
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
.font(.system(size: 12, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.textSelection(.enabled)
@@ -457,15 +468,15 @@ struct ContentView: View {
}
}) {
let senderColor = viewModel.getSenderColor(for: message, colorScheme: colorScheme)
Text("<\(message.sender)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
Text("<@\(message.sender)>")
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundColor(senderColor)
}
.buttonStyle(.plain)
} else {
// Own messages not tappable
Text("<\(message.sender)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
Text("<@\(message.sender)>")
.font(.system(size: 14, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.textSelection(.enabled)
}
@@ -572,21 +583,21 @@ struct ContentView: View {
HStack(alignment: .center, spacing: 4) {
if viewModel.selectedPrivateChatPeer != nil {
Text("<\(viewModel.nickname)> →")
Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
Text("<\(viewModel.nickname)> →")
Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else {
Text("<\(viewModel.nickname)>")
Text("<@\(viewModel.nickname)>")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(1)