UI improvements and rename rooms to channels

- Changed system messages from green to grey with consistent 12pt font
- Fixed text wrapping to flow naturally under timestamps
- Changed default nickname to anonXXXX format
- Replaced text with icon representations in status bar
- Added icons to sidebar section headers
- Made autocomplete UI consistent between commands and @mentions
- Added welcome message for new users (3 second delay)
- Changed sidebar header to 'YOUR NETWORK'
- Added command aliases (/join, /msg)
- Implemented /hug and /slap commands with haptic feedback
- Improved command help display with alphabetization
- Renamed 'rooms' to 'channels' throughout entire codebase
This commit is contained in:
jack
2025-07-08 01:42:35 +02:00
parent d3c1b77015
commit 9794f3ebdc
15 changed files with 1548 additions and 1163 deletions
+9 -9
View File
@@ -43,7 +43,7 @@ class BitchatMessageTests: XCTestCase {
}
func testRoomMessage() {
let roomMessage = BitchatMessage(
let channelMessage = BitchatMessage(
sender: "alice",
content: "Hello #general",
timestamp: Date(),
@@ -53,21 +53,21 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: "#general"
channel: "#general"
)
guard let encoded = roomMessage.toBinaryPayload() else {
XCTFail("Failed to encode room message")
guard let encoded = channelMessage.toBinaryPayload() else {
XCTFail("Failed to encode channel message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode room message")
XCTFail("Failed to decode channel message")
return
}
XCTAssertEqual(decoded.room, "#general")
XCTAssertEqual(decoded.content, roomMessage.content)
XCTAssertEqual(decoded.channel, "#general")
XCTAssertEqual(decoded.content, channelMessage.content)
}
func testEncryptedRoomMessage() {
@@ -83,7 +83,7 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "bob456",
mentions: nil,
room: "#secret",
channel: "#secret",
encryptedContent: encryptedData,
isEncrypted: true
)
@@ -100,7 +100,7 @@ class BitchatMessageTests: XCTestCase {
XCTAssertTrue(decoded.isEncrypted)
XCTAssertEqual(decoded.encryptedContent, encryptedData)
XCTAssertEqual(decoded.room, "#secret")
XCTAssertEqual(decoded.channel, "#secret")
XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages
}
@@ -1,5 +1,5 @@
//
// PasswordProtectedRoomTests.swift
// PasswordProtectedChannelTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
@@ -11,7 +11,7 @@ import CryptoKit
import CommonCrypto
@testable import bitchat
class PasswordProtectedRoomTests: XCTestCase {
class PasswordProtectedChannelTests: XCTestCase {
var viewModel: ChatViewModel!
override func setUp() {
@@ -23,22 +23,22 @@ class PasswordProtectedRoomTests: XCTestCase {
viewModel = ChatViewModel()
// Ensure clean state
viewModel.passwordProtectedRooms.removeAll()
viewModel.roomCreators.removeAll()
viewModel.roomPasswords.removeAll()
viewModel.roomKeys.removeAll()
viewModel.joinedRooms.removeAll()
viewModel.roomMembers.removeAll()
viewModel.roomMessages.removeAll()
viewModel.passwordProtectedChannels.removeAll()
viewModel.channelCreators.removeAll()
viewModel.channelPasswords.removeAll()
viewModel.channelKeys.removeAll()
viewModel.joinedChannels.removeAll()
viewModel.channelMembers.removeAll()
viewModel.channelMessages.removeAll()
}
private func clearAllUserDefaults() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "bitchat_nickname")
defaults.removeObject(forKey: "bitchat_joined_rooms")
defaults.removeObject(forKey: "bitchat_password_protected_rooms")
defaults.removeObject(forKey: "bitchat_room_creators")
defaults.removeObject(forKey: "bitchat_room_passwords")
defaults.removeObject(forKey: "bitchat_joined_channels")
defaults.removeObject(forKey: "bitchat_password_protected_channels")
defaults.removeObject(forKey: "bitchat_channel_creators")
defaults.removeObject(forKey: "bitchat_channel_passwords")
defaults.removeObject(forKey: "bitchat_favorite_peers")
defaults.synchronize()
}
@@ -53,100 +53,100 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Key Derivation Tests
func testPasswordKeyDerivation() {
// Same password and room should always produce same key
// Same password and channel should always produce same key
let password = "secretPassword123"
let roomName = "#testroom"
let channelName = "#testchannel"
let key1 = deriveRoomKey(from: password, roomName: roomName)
let key2 = deriveRoomKey(from: password, roomName: roomName)
let key1 = deriveChannelKey(from: password, channelName: channelName)
let key2 = deriveChannelKey(from: password, channelName: channelName)
// Keys should be identical
XCTAssertEqual(key1, key2, "Same password and room should produce same key")
XCTAssertEqual(key1, key2, "Same password and channel should produce same key")
}
func testDifferentPasswordsProduceDifferentKeys() {
let roomName = "#testroom"
let channelName = "#testchannel"
let password1 = "password123"
let password2 = "different456"
let key1 = deriveRoomKey(from: password1, roomName: roomName)
let key2 = deriveRoomKey(from: password2, roomName: roomName)
let key1 = deriveChannelKey(from: password1, channelName: channelName)
let key2 = deriveChannelKey(from: password2, channelName: channelName)
XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys")
}
func testDifferentRoomsProduceDifferentKeys() {
func testDifferentChannelsProduceDifferentKeys() {
let password = "samePassword"
let room1 = "#room1"
let room2 = "#room2"
let channel1 = "#channel1"
let channel2 = "#channel2"
let key1 = deriveRoomKey(from: password, roomName: room1)
let key2 = deriveRoomKey(from: password, roomName: room2)
let key1 = deriveChannelKey(from: password, channelName: channel1)
let key2 = deriveChannelKey(from: password, channelName: channel2)
XCTAssertNotEqual(key1, key2, "Same password in different rooms should produce different keys")
XCTAssertNotEqual(key1, key2, "Same password in different channels should produce different keys")
}
// MARK: - Room Creation and Joining Tests
// MARK: - Channel Creation and Joining Tests
func testJoinUnprotectedRoom() {
let roomName = "#public"
func testJoinUnprotectedChannel() {
let channelName = "#public"
let success = viewModel.joinRoom(roomName)
let success = viewModel.joinChannel(channelName)
XCTAssertTrue(success, "Should be able to join unprotected room")
XCTAssertTrue(viewModel.joinedRooms.contains(roomName))
XCTAssertEqual(viewModel.currentRoom, roomName)
XCTAssertTrue(viewModel.roomMembers[roomName]?.contains(viewModel.meshService.myPeerID) ?? false)
XCTAssertTrue(success, "Should be able to join unprotected channel")
XCTAssertTrue(viewModel.joinedChannels.contains(channelName))
XCTAssertEqual(viewModel.currentChannel, channelName)
XCTAssertTrue(viewModel.channelMembers[channelName]?.contains(viewModel.meshService.myPeerID) ?? false)
}
func testCreatePasswordProtectedRoom() {
let roomName = "#private"
func testCreatePasswordProtectedChannel() {
let channelName = "#private"
let password = "secret123"
// Join room first
let joinSuccess = viewModel.joinRoom(roomName)
// Join channel first
let joinSuccess = viewModel.joinChannel(channelName)
XCTAssertTrue(joinSuccess)
// Set password
viewModel.setRoomPassword(password, for: roomName)
viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
XCTAssertNotNil(viewModel.roomKeys[roomName])
XCTAssertEqual(viewModel.roomPasswords[roomName], password)
XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
XCTAssertNotNil(viewModel.channelKeys[channelName])
XCTAssertEqual(viewModel.channelPasswords[channelName], password)
XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
}
func testJoinPasswordProtectedEmptyRoom() {
let roomName = "#protected"
func testJoinPasswordProtectedEmptyChannel() {
let channelName = "#protected"
let password = "test123"
// Simulate room being marked as password protected
viewModel.passwordProtectedRooms.insert(roomName)
// Simulate channel being marked as password protected
viewModel.passwordProtectedChannels.insert(channelName)
// Try to join with password - should be accepted tentatively for empty room
let success = viewModel.joinRoom(roomName, password: password)
// Try to join with password - should be accepted tentatively for empty channel
let success = viewModel.joinChannel(channelName, password: password)
XCTAssertTrue(success, "Should accept tentative access to empty password-protected room")
XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key tentatively")
XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Should store password tentatively")
XCTAssertTrue(success, "Should accept tentative access to empty password-protected channel")
XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key tentatively")
XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Should store password tentatively")
// Should have a system message explaining tentative access
let hasSystemMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("waiting for encrypted messages to verify password") }
XCTAssertTrue(hasSystemMessage, "Should add system message explaining tentative access")
}
func testJoinPasswordProtectedRoomWithMessages() {
let roomName = "#secure"
func testJoinPasswordProtectedChannelWithMessages() {
let channelName = "#secure"
let correctPassword = "correct123"
let wrongPassword = "wrong456"
let testMessage = "Test encrypted message"
// First, create the room and set password as creator
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(correctPassword, for: roomName)
// First, create the channel and set password as creator
let _ = viewModel.joinChannel(channelName)
viewModel.setChannelPassword(correctPassword, for: channelName)
// Simulate an encrypted message in the room
let key = viewModel.roomKeys[roomName]!
// Simulate an encrypted message in the channel
let key = viewModel.channelKeys[channelName]!
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
return
@@ -166,26 +166,26 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: roomName,
channel: channelName,
encryptedContent: encryptedData,
isEncrypted: true
)
// Add to room messages
viewModel.roomMessages[roomName] = [encryptedMsg]
// Add to channel messages
viewModel.channelMessages[channelName] = [encryptedMsg]
// Clear keys to simulate another user
viewModel.roomKeys.removeValue(forKey: roomName)
viewModel.roomPasswords.removeValue(forKey: roomName)
viewModel.channelKeys.removeValue(forKey: channelName)
viewModel.channelPasswords.removeValue(forKey: channelName)
// Try to join with wrong password
let wrongSuccess = viewModel.joinRoom(roomName, password: wrongPassword)
let wrongSuccess = viewModel.joinChannel(channelName, password: wrongPassword)
XCTAssertFalse(wrongSuccess, "Should reject wrong password")
// Try to join with correct password
let correctSuccess = viewModel.joinRoom(roomName, password: correctPassword)
let correctSuccess = viewModel.joinChannel(channelName, password: correctPassword)
XCTAssertTrue(correctSuccess, "Should accept correct password")
XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key for correct password")
XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key for correct password")
} catch {
XCTFail("Encryption failed: \(error)")
@@ -194,13 +194,13 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Verification Tests
func testEncryptDecryptRoomMessage() {
let roomName = "#crypto"
func testEncryptDecryptChannelMessage() {
let channelName = "#crypto"
let password = "cryptoKey"
let testMessage = "This is a secret message"
// Derive key
let key = deriveRoomKey(from: password, roomName: roomName)
let key = deriveChannelKey(from: password, channelName: channelName)
// Encrypt
guard let messageData = testMessage.data(using: .utf8) else {
@@ -213,8 +213,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined!
// Store key and decrypt
viewModel.roomKeys[roomName] = key
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName)
viewModel.channelKeys[channelName] = key
let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName)
XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original")
} catch {
@@ -223,13 +223,13 @@ class PasswordProtectedRoomTests: XCTestCase {
}
func testWrongPasswordFailsDecryption() {
let roomName = "#secure"
let channelName = "#secure"
let correctPassword = "correct"
let wrongPassword = "wrong"
let testMessage = "Secret content"
// Encrypt with correct password
let correctKey = deriveRoomKey(from: correctPassword, roomName: roomName)
let correctKey = deriveChannelKey(from: correctPassword, channelName: channelName)
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
@@ -241,8 +241,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined!
// Try to decrypt with wrong password
let wrongKey = deriveRoomKey(from: wrongPassword, roomName: roomName)
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName, testKey: wrongKey)
let wrongKey = deriveChannelKey(from: wrongPassword, channelName: channelName)
let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName, testKey: wrongKey)
XCTAssertNil(decrypted, "Wrong password should fail to decrypt")
} catch {
@@ -250,52 +250,52 @@ class PasswordProtectedRoomTests: XCTestCase {
}
}
// MARK: - Room Creator Tests
// MARK: - Channel Creator Tests
func testOnlyCreatorCanSetPassword() {
let roomName = "#owned"
let channelName = "#owned"
let password = "ownerOnly"
// Join room (becomes creator)
let _ = viewModel.joinRoom(roomName)
// Join channel (becomes creator)
let _ = viewModel.joinChannel(channelName)
// Set password as creator
viewModel.setRoomPassword(password, for: roomName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Simulate another user trying to set password
viewModel.roomCreators[roomName] = "otherUser123"
viewModel.setRoomPassword("hackerPassword", for: roomName)
viewModel.channelCreators[channelName] = "otherUser123"
viewModel.setChannelPassword("hackerPassword", for: channelName)
// Password should not change
XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Non-creator should not be able to change password")
XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Non-creator should not be able to change password")
}
func testCreatorCanRemovePassword() {
let roomName = "#changeable"
let channelName = "#changeable"
let password = "temporary"
// Create protected room
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
// Create protected channel
let _ = viewModel.joinChannel(channelName)
viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Remove password
viewModel.removeRoomPassword(for: roomName)
viewModel.removeChannelPassword(for: channelName)
XCTAssertFalse(viewModel.passwordProtectedRooms.contains(roomName))
XCTAssertNil(viewModel.roomKeys[roomName])
XCTAssertNil(viewModel.roomPasswords[roomName])
XCTAssertFalse(viewModel.passwordProtectedChannels.contains(channelName))
XCTAssertNil(viewModel.channelKeys[channelName])
XCTAssertNil(viewModel.channelPasswords[channelName])
}
// MARK: - Message Handling Tests
func testReceiveEncryptedMessageWithoutKey() {
let roomName = "#encrypted"
let channelName = "#encrypted"
// Join room without password
let _ = viewModel.joinRoom(roomName)
// Join channel without password
let _ = viewModel.joinChannel(channelName)
// Simulate receiving encrypted message
let encryptedMessage = BitchatMessage(
@@ -308,109 +308,109 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
room: roomName,
channel: channelName,
encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data
isEncrypted: true
)
viewModel.didReceiveMessage(encryptedMessage)
// Should mark room as password protected
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
// Should mark channel as password protected
XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Should add system message
let roomMessages = viewModel.roomMessages[roomName] ?? []
let hasSystemMessage = roomMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
let channelMessages = viewModel.channelMessages[channelName] ?? []
let hasSystemMessage = channelMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
XCTAssertTrue(hasSystemMessage, "Should add system message about password protection")
}
// MARK: - Command Tests
func testJoinCommand() {
let input = "/join #testroom"
let input = "/join #testchannel"
viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#testroom"))
XCTAssertEqual(viewModel.currentRoom, "#testroom")
XCTAssertTrue(viewModel.joinedChannels.contains("#testchannel"))
XCTAssertEqual(viewModel.currentChannel, "#testchannel")
}
func testJoinCommandAlias() {
let input = "/j #quick"
viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#quick"))
XCTAssertEqual(viewModel.currentRoom, "#quick")
XCTAssertTrue(viewModel.joinedChannels.contains("#quick"))
XCTAssertEqual(viewModel.currentChannel, "#quick")
}
func testInvalidRoomName() {
let input = "/j #invalid-room!"
func testInvalidChannelName() {
let input = "/j #invalid-channel!"
viewModel.sendMessage(input)
XCTAssertFalse(viewModel.joinedRooms.contains("#invalid-room!"))
XCTAssertFalse(viewModel.joinedChannels.contains("#invalid-channel!"))
// Should have system message about invalid name
let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("Invalid room name") }
let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("Invalid channel name") }
XCTAssertTrue(hasErrorMessage)
}
// MARK: - Key Commitment Tests
func testKeyCommitmentVerification() {
let roomName = "#commitment"
let channelName = "#commitment"
let password = "testpass123"
// Join and set password
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
let _ = viewModel.joinChannel(channelName)
viewModel.setChannelPassword(password, for: channelName)
// Verify key commitment was stored
XCTAssertNotNil(viewModel.roomKeyCommitments[roomName], "Should store key commitment")
XCTAssertNotNil(viewModel.channelKeyCommitments[channelName], "Should store key commitment")
// Simulate another user with the stored commitment
let commitment = viewModel.roomKeyCommitments[roomName]!
viewModel.roomKeys.removeValue(forKey: roomName)
viewModel.roomPasswords.removeValue(forKey: roomName)
let commitment = viewModel.channelKeyCommitments[channelName]!
viewModel.channelKeys.removeValue(forKey: channelName)
viewModel.channelPasswords.removeValue(forKey: channelName)
// Manually set the commitment as if received from network
viewModel.roomKeyCommitments[roomName] = commitment
viewModel.channelKeyCommitments[channelName] = commitment
// Try with wrong password - should fail immediately
let wrongSuccess = viewModel.joinRoom(roomName, password: "wrongpass")
let wrongSuccess = viewModel.joinChannel(channelName, password: "wrongpass")
XCTAssertFalse(wrongSuccess, "Should reject wrong password via commitment check")
// Try with correct password - should succeed
let correctSuccess = viewModel.joinRoom(roomName, password: password)
let correctSuccess = viewModel.joinChannel(channelName, password: password)
XCTAssertTrue(correctSuccess, "Should accept correct password via commitment check")
}
func testOwnershipTransfer() {
let roomName = "#transfertest"
let channelName = "#transfertest"
let password = "ownerpass"
// Create room and set password
let _ = viewModel.joinRoom(roomName)
viewModel.setRoomPassword(password, for: roomName)
// Create channel and set password
let _ = viewModel.joinChannel(channelName)
viewModel.setChannelPassword(password, for: channelName)
// Verify creator is set
XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
// Simulate transfer (in real app would use /transfer command)
let newOwnerID = "newowner123"
viewModel.roomCreators[roomName] = newOwnerID
viewModel.channelCreators[channelName] = newOwnerID
// Verify ownership changed
XCTAssertEqual(viewModel.roomCreators[roomName], newOwnerID)
XCTAssertNotEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
XCTAssertEqual(viewModel.channelCreators[channelName], newOwnerID)
XCTAssertNotEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
}
}
// MARK: - Helper Extensions for Testing
extension PasswordProtectedRoomTests {
// Helper method to derive room key for testing
extension PasswordProtectedChannelTests {
// Helper method to derive channel key for testing
// This duplicates the logic from ChatViewModel for testing purposes
func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey {
let salt = roomName.data(using: .utf8)!
func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
let salt = channelName.data(using: .utf8)!
let iterations = 100000
let keyLength = 32