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
+11 -11
View File
@@ -11,11 +11,11 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
- **Room-Based Chats**: Topic-based group messaging with optional password protection
- **Channel-Based Chats**: Topic-based group messaging with optional password protection
- **Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **Message Retention**: Optional room-wide message saving controlled by room owners
- **Message Retention**: Optional channel-wide message saving controlled by channel owners
- **Universal App**: Native support for iOS and macOS
- **Cover Traffic**: Timing obfuscation and dummy messages for enhanced privacy
- **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -62,26 +62,26 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
### Basic Commands
- `/j #room` - Join or create a room
- `/j #channel` - Join or create a channel
- `/m @user message` - Send a private message
- `/w` - List online users
- `/rooms` - Show all discovered rooms
- `/channels` - Show all discovered channels
- `/clear` - Clear chat messages
- `/pass [password]` - Set/change room password (owner only)
- `/transfer @user` - Transfer room ownership
- `/save` - Toggle message retention for room (owner only)
- `/pass [password]` - Set/change channel password (owner only)
- `/transfer @user` - Transfer channel ownership
- `/save` - Toggle message retention for channel (owner only)
### Getting Started
1. Launch bitchat on your device
2. Set your nickname (or use the auto-generated one)
3. You'll automatically connect to nearby peers
4. Join a room with `/j #general` or start chatting in public
4. Join a channel with `/j #general` or start chatting in public
5. Messages relay through the mesh network to reach distant peers
### Room Features
### Channel Features
- **Password Protection**: Room owners can set passwords with `/pass`
- **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`
@@ -90,7 +90,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
### Encryption
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
- **Room Messages**: Argon2id password derivation + AES-256-GCM
- **Channel Messages**: Argon2id password derivation + AES-256-GCM
- **Digital Signatures**: Ed25519 for message authenticity
- **Forward Secrecy**: New key pairs generated each session
+23 -23
View File
@@ -12,7 +12,7 @@ bitchat is a decentralized, peer-to-peer messaging application that operates ove
4. [Message Relay Protocol](#message-relay-protocol)
5. [Store and Forward Mechanism](#store-and-forward-mechanism)
6. [Encryption and Security](#encryption-and-security)
7. [Room-Based Communication](#room-based-communication)
7. [Channel-Based Communication](#channel-based-communication)
8. [Binary Protocol Specification](#binary-protocol-specification)
9. [Privacy Features](#privacy-features)
10. [Message Fragmentation](#message-fragmentation)
@@ -39,7 +39,7 @@ graph TB
subgraph "Application Layer"
UI[Chat UI]
CMD[Commands]
ROOM[Room Management]
ROOM[Channel Management]
end
subgraph "Service Layer"
@@ -342,10 +342,10 @@ sequenceDiagram
### Encryption Layers
1. **Private Messages**: X25519 key exchange + AES-256-GCM
2. **Room Messages**: Password-derived keys using Argon2id
2. **Channel Messages**: Password-derived keys using Argon2id
3. **Digital Signatures**: Ed25519 for message authenticity
### Key Derivation for Rooms
### Key Derivation for Channels
<div align="center">
@@ -355,7 +355,7 @@ graph LR
A --> K[256-bit Key]
K --> AES[AES-256-GCM]
S[Salt - SHA256 of roomName] --> A
S[Salt - SHA256 of channelName] --> A
I[Iterations - 10] --> A
M[Memory - 64MB] --> A
T[Parallelism - 4] --> A
@@ -368,27 +368,27 @@ graph LR
</div>
## Room-Based Communication
## Channel-Based Communication
Rooms provide topic-based group messaging with optional password protection.
Channels provide topic-based group messaging with optional password protection.
### Room State Machine
### Channel State Machine
<div align="center">
```mermaid
stateDiagram-v2
[*] --> Discovery
Discovery --> Joined: /j #room
Joined --> PasswordPrompt: Room is protected
Joined --> Unlocked: Room is public
Discovery --> Joined: /j #channel
Joined --> PasswordPrompt: Channel is protected
Joined --> Unlocked: Channel is public
PasswordPrompt --> Unlocked: Correct password
PasswordPrompt --> PasswordPrompt: Wrong password
Unlocked --> [*]: Leave room
Unlocked --> [*]: Leave channel
state Discovery {
[*] --> Scanning
Scanning --> Found: Room activity detected
Scanning --> Found: Channel activity detected
}
state Unlocked {
@@ -402,13 +402,13 @@ stateDiagram-v2
</div>
### Room Features
### Channel Features
- **Hashtag naming**: Rooms identified by #roomname
- **Hashtag naming**: Channels identified by #channelname
- **Password protection**: Optional encryption with shared passwords
- **Owner privileges**: Transfer ownership, change passwords
- **Message retention**: Owner-controlled mandatory retention
- **Decentralized discovery**: Rooms discovered through usage
- **Decentralized discovery**: Channels discovered through usage
## Binary Protocol Specification
@@ -467,8 +467,8 @@ classDiagram
| FRAGMENT_START | 0x05 | Start of fragmented message |
| FRAGMENT_CONTINUE | 0x06 | Continuation fragment |
| FRAGMENT_END | 0x07 | Final fragment |
| ROOM_ANNOUNCE | 0x08 | Room status announcement |
| ROOM_RETENTION | 0x09 | Room retention policy |
| ROOM_ANNOUNCE | 0x08 | Channel status announcement |
| ROOM_RETENTION | 0x09 | Channel retention policy |
## Performance Optimizations
@@ -680,7 +680,7 @@ sequenceDiagram
alt Private Message
E->>E: Encrypt with X25519<br/>shared secret
else Room Message
else Channel Message
E->>E: Encrypt with Argon2id<br/>derived key
else Broadcast
E->>E: Sign with Ed25519
@@ -797,7 +797,7 @@ graph TB
**2. Asynchronous Delivery**: Nostr's event-based model aligns well with bitchat's store-and-forward mechanism, enabling message delivery across time zones and sporadic connectivity.
**3. Selective Sharing**: Users could opt-in to share specific rooms or conversations beyond the local mesh, maintaining privacy by default.
**3. Selective Sharing**: Users could opt-in to share specific channels or conversations beyond the local mesh, maintaining privacy by default.
**4. Decentralized Architecture**: Nostr's relay model preserves bitchat's decentralization principles - no single point of failure or control.
@@ -813,7 +813,7 @@ sequenceDiagram
M->>G: Message for remote delivery
G->>G: Check opt-in status
alt Room allows bridging
alt Channel allows bridging
G->>N: Convert to Nostr event
Note over N: Add bitchat metadata<br/>Maintain encryption
N->>R: Publish event
@@ -832,7 +832,7 @@ sequenceDiagram
Key considerations for maintaining bitchat's privacy model:
1. **Opt-in Only**: Network bridging disabled by default, requiring explicit user consent
2. **Room-Level Control**: Bridge permissions managed per room, not globally
2. **Channel-Level Control**: Bridge permissions managed per channel, not globally
3. **Maintained Encryption**: Messages remain end-to-end encrypted when bridged
4. **Ephemeral Options**: Support for Nostr's ephemeral events (NIP-16) for temporary bridging
5. **Identity Isolation**: Generate separate Nostr keypairs unlinked to local peer identities
@@ -841,7 +841,7 @@ Key considerations for maintaining bitchat's privacy model:
- **Disaster Coordination**: Bridge local emergency mesh networks to coordinate broader relief efforts
- **Event Overflow**: Extend large gatherings beyond Bluetooth range while maintaining local clusters
- **Checkpoint Sync**: Periodically sync specific rooms when internet is briefly available
- **Checkpoint Sync**: Periodically sync specific channels when internet is briefly available
- **Cross-Community Bridges**: Connect related but geographically separated communities
This extension would be implemented as an optional module, ensuring the core bitchat system remains fully functional without any network dependencies. Users in pure offline environments would see no change, while those with selective connectivity could benefit from enhanced reach when desired.
+13 -13
View File
@@ -227,7 +227,7 @@ extension BitchatMessage {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasRoom, bit 7: isEncrypted)
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasChannel, bit 7: isEncrypted)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
@@ -240,7 +240,7 @@ extension BitchatMessage {
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
// - Room hashtag
// - Channel hashtag
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
@@ -249,7 +249,7 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
if room != nil { flags |= 0x40 }
if channel != nil { flags |= 0x40 }
if isEncrypted { flags |= 0x80 }
data.append(flags)
@@ -323,10 +323,10 @@ extension BitchatMessage {
}
}
// Room hashtag
if let room = room, let roomData = room.data(using: .utf8) {
data.append(UInt8(min(roomData.count, 255)))
data.append(roomData.prefix(255))
// Channel hashtag
if let channel = channel, let channelData = channel.data(using: .utf8) {
data.append(UInt8(min(channelData.count, 255)))
data.append(channelData.prefix(255))
}
return data
@@ -354,7 +354,7 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
let hasRoom = (flags & 0x40) != 0
let hasChannel = (flags & 0x40) != 0
let isEncrypted = (flags & 0x80) != 0
// Timestamp
@@ -465,12 +465,12 @@ extension BitchatMessage {
}
}
// Room
var room: String? = nil
if hasRoom && offset < dataCopy.count {
// Channel
var channel: String? = nil
if hasChannel && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
room = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
channel = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
@@ -486,7 +486,7 @@ extension BitchatMessage {
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions,
room: room,
channel: channel,
encryptedContent: encryptedContent,
isEncrypted: isEncrypted
)
+13 -13
View File
@@ -72,8 +72,8 @@ enum MessageType: UInt8 {
case fragmentStart = 0x05
case fragmentContinue = 0x06
case fragmentEnd = 0x07
case roomAnnounce = 0x08 // Announce password-protected room status
case roomRetention = 0x09 // Announce room retention status
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
@@ -220,12 +220,12 @@ struct BitchatMessage: Codable, Equatable {
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
let room: String? // Room hashtag (e.g., "#general")
let channel: String? // Channel hashtag (e.g., "#general")
let encryptedContent: Data? // For password-protected rooms
let isEncrypted: Bool // Flag to indicate if content is encrypted
var deliveryStatus: DeliveryStatus? // Delivery tracking
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) {
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, channel: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
@@ -236,7 +236,7 @@ struct BitchatMessage: Codable, Equatable {
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.room = room
self.channel = channel
self.encryptedContent = encryptedContent
self.isEncrypted = isEncrypted
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
@@ -248,10 +248,10 @@ protocol BitchatDelegate: AnyObject {
func didConnectToPeer(_ peerID: String)
func didDisconnectFromPeer(_ peerID: String)
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?
func didReceiveChannelLeave(_ channel: String, from peerID: String)
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?)
func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String?
// Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool
@@ -268,19 +268,19 @@ extension BitchatDelegate {
return false
}
func didReceiveRoomLeave(_ room: String, from peerID: String) {
func didReceiveChannelLeave(_ channel: String, from peerID: String) {
// Default empty implementation
}
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
// Default empty implementation
}
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
// Default empty implementation
}
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? {
func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String? {
// Default returns nil (unable to decrypt)
return nil
}
+47 -47
View File
@@ -489,7 +489,7 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic
}
func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil) {
func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -506,7 +506,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
room: room
channel: channel
)
if let messageData = message.toBinaryPayload() {
@@ -653,17 +653,17 @@ class BluetoothMeshService: NSObject {
}
}
func sendRoomLeaveNotification(_ room: String) {
func sendChannelLeaveNotification(_ channel: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Create a leave packet with room hashtag as payload
// Create a leave packet with channel hashtag as payload
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast, // Broadcast to all
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(room.utf8), // Room hashtag as payload
payload: Data(channel.utf8), // Channel hashtag as payload
signature: nil,
ttl: 3 // Short TTL for leave notifications
)
@@ -738,53 +738,53 @@ class BluetoothMeshService: NSObject {
}
}
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
func announcePasswordProtectedChannel(_ channel: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: room|isProtected|creatorID|keyCommitment
// Payload format: channel|isProtected|creatorID|keyCommitment
let protectedFlag = isProtected ? "1" : "0"
let creator = creatorID ?? self.myPeerID
let commitment = keyCommitment ?? ""
let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)"
let payload = "\(channel)|\(protectedFlag)|\(creator)|\(commitment)"
let packet = BitchatPacket(
type: MessageType.roomAnnounce.rawValue,
type: MessageType.channelAnnounce.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
ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
func sendRoomRetentionAnnouncement(_ room: String, enabled: Bool) {
func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: room|enabled|creatorID
// Payload format: channel|enabled|creatorID
let enabledFlag = enabled ? "1" : "0"
let payload = "\(room)|\(enabledFlag)|\(self.myPeerID)"
let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
let packet = BitchatPacket(
type: MessageType.roomRetention.rawValue,
type: MessageType.channelRetention.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
ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey) {
func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -797,7 +797,7 @@ class BluetoothMeshService: NSObject {
// Debug logging removed
do {
let sealedBox = try AES.GCM.seal(contentData, using: roomKey)
let sealedBox = try AES.GCM.seal(contentData, using: channelKey)
let encryptedData = sealedBox.combined!
// Create message with encrypted content
@@ -811,7 +811,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
room: room,
channel: channel,
encryptedContent: encryptedData,
isEncrypted: true
)
@@ -1244,7 +1244,7 @@ class BluetoothMeshService: NSObject {
MessageRetryService.shared.addMessageForRetry(
content: message.content,
mentions: message.mentions,
room: message.room,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname
@@ -1298,24 +1298,24 @@ class BluetoothMeshService: NSObject {
if sentToPeripherals == 0 && sentToCentrals == 0 {
if packet.type == MessageType.message.rawValue,
let message = BitchatMessage.fromBinaryPayload(packet.payload) {
// For encrypted room messages, we need to preserve the room key
var roomKeyData: Data? = nil
if let room = message.room, message.isEncrypted {
// This is an encrypted room message
// For encrypted channel messages, we need to preserve the channel key
var channelKeyData: Data? = nil
if let channel = message.channel, message.isEncrypted {
// This is an encrypted channel message
if let viewModel = delegate as? ChatViewModel,
let roomKey = viewModel.roomKeys[room] {
roomKeyData = roomKey.withUnsafeBytes { Data($0) }
let channelKey = viewModel.channelKeys[channel] {
channelKeyData = channelKey.withUnsafeBytes { Data($0) }
}
}
MessageRetryService.shared.addMessageForRetry(
content: message.content,
mentions: message.mentions,
room: message.room,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname,
roomKey: roomKeyData
channelKey: channelKeyData
)
}
}
@@ -1430,11 +1430,11 @@ class BluetoothMeshService: NSObject {
peerNicknames[senderID] = message.sender
peerNicknamesLock.unlock()
// Handle encrypted room messages
// Handle encrypted channel messages
var finalContent = message.content
if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent {
if message.isEncrypted, let channel = message.channel, let encryptedData = message.encryptedContent {
// Try to decrypt the content
if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) {
if let decryptedContent = self.delegate?.decryptChannelMessage(encryptedData, channel: channel) {
finalContent = decryptedContent
} else {
// Unable to decrypt - show placeholder
@@ -1453,7 +1453,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: senderID,
mentions: message.mentions,
room: message.room,
channel: message.channel,
encryptedContent: message.encryptedContent,
isEncrypted: message.isEncrypted
)
@@ -1467,10 +1467,10 @@ class BluetoothMeshService: NSObject {
self.delegate?.didReceiveMessage(messageWithPeerID)
}
// Generate and send ACK for room messages if we're mentioned or it's a small room
// Generate and send ACK for channel messages if we're mentioned or it's a small channel
let viewModel = self.delegate as? ChatViewModel
let myNickname = viewModel?.nickname ?? self.myPeerID
if let _ = message.room,
if let _ = message.channel,
let mentions = message.mentions,
(mentions.contains(myNickname) || self.activePeers.count < 10) {
if let ack = DeliveryTracker.shared.generateAck(
@@ -1575,7 +1575,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: message.recipientNickname,
senderPeerID: senderID,
mentions: message.mentions,
room: message.room,
channel: message.channel,
deliveryStatus: nil // Will be set to .delivered in ChatViewModel
)
@@ -1867,13 +1867,13 @@ class BluetoothMeshService: NSObject {
case .leave:
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
// Check if payload contains a room hashtag
if let room = String(data: packet.payload, encoding: .utf8),
room.hasPrefix("#") {
// Room leave notification
// Check if payload contains a channel hashtag
if let channel = String(data: packet.payload, encoding: .utf8),
channel.hasPrefix("#") {
// Channel leave notification
DispatchQueue.main.async {
self.delegate?.didReceiveRoomLeave(room, from: senderID)
self.delegate?.didReceiveChannelLeave(channel, from: senderID)
}
// Relay if TTL > 0
@@ -1924,19 +1924,19 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket)
}
case .roomAnnounce:
case .channelAnnounce:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: room|isProtected|creatorID|keyCommitment
// Parse payload: channel|isProtected|creatorID|keyCommitment
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let room = components[0]
let channel = components[0]
let isProtected = components[1] == "1"
let creatorID = components[2]
let keyCommitment = components.count >= 4 ? components[3] : nil
DispatchQueue.main.async {
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
self.delegate?.didReceivePasswordProtectedChannelAnnouncement(channel, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
}
// Relay announcement
@@ -1977,18 +1977,18 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket)
}
case .roomRetention:
case .channelRetention:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: room|enabled|creatorID
// Parse payload: channel|enabled|creatorID
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let room = components[0]
let channel = components[0]
let enabled = components[1] == "1"
let creatorID = components[2]
DispatchQueue.main.async {
self.delegate?.didReceiveRoomRetentionAnnouncement(room, enabled: enabled, creatorID: creatorID)
self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
}
// Relay announcement
+15 -15
View File
@@ -41,19 +41,19 @@ class DeliveryTracker {
let recipientID: String
let recipientNickname: String
let retryCount: Int
let isRoomMessage: Bool
let isChannelMessage: Bool
let isFavorite: Bool
var ackedBy: Set<String> = [] // For tracking partial room delivery
let expectedRecipients: Int // For room messages
var ackedBy: Set<String> = [] // For tracking partial channel delivery
let expectedRecipients: Int // For channel messages
var timeoutTimer: Timer?
var isTimedOut: Bool {
let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30)
let timeout: TimeInterval = isFavorite ? 300 : (isChannelMessage ? 60 : 30)
return Date().timeIntervalSince(sentAt) > timeout
}
var shouldRetry: Bool {
return retryCount < 3 && isFavorite && !isRoomMessage
return retryCount < 3 && isFavorite && !isChannelMessage
}
}
@@ -69,7 +69,7 @@ class DeliveryTracker {
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
// Don't track broadcasts or certain message types
guard message.isPrivate || message.room != nil else { return }
guard message.isPrivate || message.channel != nil else { return }
let delivery = PendingDelivery(
@@ -78,7 +78,7 @@ class DeliveryTracker {
recipientID: recipientID,
recipientNickname: recipientNickname,
retryCount: 0,
isRoomMessage: message.room != nil,
isChannelMessage: message.channel != nil,
isFavorite: isFavorite,
expectedRecipients: expectedRecipients,
timeoutTimer: nil
@@ -118,8 +118,8 @@ class DeliveryTracker {
// Cancel timeout timer
delivery.timeoutTimer?.invalidate()
if delivery.isRoomMessage {
// Track partial delivery for room messages
if delivery.isChannelMessage {
// Track partial delivery for channel messages
delivery.ackedBy.insert(ack.recipientID)
pendingDeliveries[ack.originalMessageID] = delivery
@@ -146,7 +146,7 @@ class DeliveryTracker {
guard message.senderPeerID != myPeerID else { return nil }
// Don't ACK broadcasts or system messages
guard message.isPrivate || message.room != nil else { return nil }
guard message.isPrivate || message.channel != nil else { return nil }
// Don't ACK if we've already sent an ACK for this message
guard !sentAckIDs.contains(message.id) else { return nil }
@@ -187,11 +187,11 @@ class DeliveryTracker {
return
}
let isFavorite = delivery.isFavorite
let isRoomMessage = delivery.isRoomMessage
let isChannelMessage = delivery.isChannelMessage
pendingLock.unlock()
let timeout = isFavorite ? favoriteTimeout :
(isRoomMessage ? roomMessageTimeout : privateMessageTimeout)
(isChannelMessage ? roomMessageTimeout : privateMessageTimeout)
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.handleTimeout(messageID: messageID)
@@ -213,7 +213,7 @@ class DeliveryTracker {
}
let shouldRetry = delivery.shouldRetry
let isRoomMessage = delivery.isRoomMessage
let isChannelMessage = delivery.isChannelMessage
if shouldRetry {
pendingLock.unlock()
@@ -221,7 +221,7 @@ class DeliveryTracker {
retryDelivery(messageID: messageID)
} else {
// Mark as failed
let reason = isRoomMessage ? "No response from room members" : "Message not delivered"
let reason = isChannelMessage ? "No response from channel members" : "Message not delivered"
pendingDeliveries.removeValue(forKey: messageID)
pendingLock.unlock()
updateDeliveryStatus(messageID, status: .failed(reason: reason))
@@ -242,7 +242,7 @@ class DeliveryTracker {
recipientID: delivery.recipientID,
recipientNickname: delivery.recipientNickname,
retryCount: delivery.retryCount + 1,
isRoomMessage: delivery.isRoomMessage,
isChannelMessage: delivery.isChannelMessage,
isFavorite: delivery.isFavorite,
ackedBy: delivery.ackedBy,
expectedRecipients: delivery.expectedRecipients,
+11 -11
View File
@@ -17,24 +17,24 @@ class KeychainManager {
private init() {}
// MARK: - Room Passwords
// MARK: - Channel Passwords
func saveRoomPassword(_ password: String, for room: String) -> Bool {
let key = "room_\(room)"
func saveChannelPassword(_ password: String, for channel: String) -> Bool {
let key = "channel_\(channel)"
return save(password, forKey: key)
}
func getRoomPassword(for room: String) -> String? {
let key = "room_\(room)"
func getChannelPassword(for channel: String) -> String? {
let key = "channel_\(channel)"
return retrieve(forKey: key)
}
func deleteRoomPassword(for room: String) -> Bool {
let key = "room_\(room)"
func deleteChannelPassword(for channel: String) -> Bool {
let key = "channel_\(channel)"
return delete(forKey: key)
}
func getAllRoomPasswords() -> [String: String] {
func getAllChannelPasswords() -> [String: String] {
var passwords: [String: String] = [:]
// Query all items
@@ -56,11 +56,11 @@ class KeychainManager {
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
if let account = item[kSecAttrAccount as String] as? String,
account.hasPrefix("room_"),
account.hasPrefix("channel_"),
let data = item[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8) {
let room = String(account.dropFirst(5)) // Remove "room_" prefix
passwords[room] = password
let channel = String(account.dropFirst(8)) // Remove "channel_" prefix
passwords[channel] = password
}
}
}
+31 -31
View File
@@ -15,7 +15,7 @@ struct StoredMessage: Codable {
let senderPeerID: String?
let content: String
let timestamp: Date
let roomTag: String?
let channelTag: String?
let isPrivate: Bool
let recipientPeerID: String?
}
@@ -25,7 +25,7 @@ class MessageRetentionService {
private let documentsDirectory: URL
private let messagesDirectory: URL
private let favoriteRoomsKey = "bitchat.favoriteRooms"
private let favoriteChannelsKey = "bitchat.favoriteChannels"
private let retentionDays = 7 // Messages retained for 7 days
private let encryptionKey: SymmetricKey
@@ -50,32 +50,32 @@ class MessageRetentionService {
cleanupOldMessages()
}
// MARK: - Favorite Rooms Management
// MARK: - Favorite Channels Management
func getFavoriteRooms() -> Set<String> {
let rooms = UserDefaults.standard.stringArray(forKey: favoriteRoomsKey) ?? []
return Set(rooms)
func getFavoriteChannels() -> Set<String> {
let channels = UserDefaults.standard.stringArray(forKey: favoriteChannelsKey) ?? []
return Set(channels)
}
func toggleFavoriteRoom(_ room: String) -> Bool {
var favorites = getFavoriteRooms()
if favorites.contains(room) {
favorites.remove(room)
// Clean up messages for this room
deleteMessagesForRoom(room)
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(room)
favorites.insert(channel)
}
UserDefaults.standard.set(Array(favorites), forKey: favoriteRoomsKey)
return favorites.contains(room)
UserDefaults.standard.set(Array(favorites), forKey: favoriteChannelsKey)
return favorites.contains(channel)
}
// MARK: - Message Storage
func saveMessage(_ message: BitchatMessage, forRoom room: String?) {
// Only save messages for favorite rooms
guard let room = room ?? message.room,
getFavoriteRooms().contains(room) else {
func saveMessage(_ message: BitchatMessage, forChannel channel: String?) {
// Only save messages for favorite channels
guard let channel = channel ?? message.channel,
getFavoriteChannels().contains(channel) else {
return
}
@@ -86,7 +86,7 @@ class MessageRetentionService {
senderPeerID: message.senderPeerID,
content: message.content,
timestamp: message.timestamp,
roomTag: message.room,
channelTag: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.senderPeerID
)
@@ -98,22 +98,22 @@ class MessageRetentionService {
guard let encryptedData = encrypt(messageData) else { return }
// Save to file
let fileName = "\(room)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
let fileName = "\(channel)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
let fileURL = messagesDirectory.appendingPathComponent(fileName)
try? encryptedData.write(to: fileURL)
}
func loadMessagesForRoom(_ room: String) -> [BitchatMessage] {
guard getFavoriteRooms().contains(room) else { return [] }
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 roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
for fileURL in roomFiles {
for fileURL in channelFiles {
if let encryptedData = try? Data(contentsOf: fileURL),
let decryptedData = decrypt(encryptedData),
let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) {
@@ -128,7 +128,7 @@ class MessageRetentionService {
recipientNickname: nil,
senderPeerID: storedMessage.senderPeerID,
mentions: nil,
room: storedMessage.roomTag
channel: storedMessage.channelTag
)
messages.append(message)
@@ -179,12 +179,12 @@ class MessageRetentionService {
}
}
func deleteMessagesForRoom(_ room: String) {
func deleteMessagesForChannel(_ channel: String) {
do {
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
for fileURL in roomFiles {
for fileURL in channelFiles {
try? FileManager.default.removeItem(at: fileURL)
}
} catch {
@@ -200,7 +200,7 @@ class MessageRetentionService {
} catch {
}
// Clear favorite rooms
UserDefaults.standard.removeObject(forKey: favoriteRoomsKey)
// Clear favorite channels
UserDefaults.standard.removeObject(forKey: favoriteChannelsKey)
}
}
+19 -19
View File
@@ -14,11 +14,11 @@ struct RetryableMessage {
let id: String
let content: String
let mentions: [String]?
let room: String?
let channel: String?
let isPrivate: Bool
let recipientPeerID: String?
let recipientNickname: String?
let roomKey: Data?
let channelKey: Data?
let retryCount: Int
let maxRetries: Int = 3
let nextRetryTime: Date
@@ -51,11 +51,11 @@ class MessageRetryService {
func addMessageForRetry(
content: String,
mentions: [String]? = nil,
room: String? = nil,
channel: String? = nil,
isPrivate: Bool = false,
recipientPeerID: String? = nil,
recipientNickname: String? = nil,
roomKey: Data? = nil
channelKey: Data? = nil
) {
// Don't queue if we're at capacity
guard retryQueue.count < maxQueueSize else {
@@ -66,11 +66,11 @@ class MessageRetryService {
id: UUID().uuidString,
content: content,
mentions: mentions,
room: room,
channel: channel,
isPrivate: isPrivate,
recipientPeerID: recipientPeerID,
recipientNickname: recipientNickname,
roomKey: roomKey,
channelKey: channelKey,
retryCount: 0,
nextRetryTime: Date().addingTimeInterval(retryInterval)
)
@@ -122,26 +122,26 @@ class MessageRetryService {
id: message.id,
content: message.content,
mentions: message.mentions,
room: message.room,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
roomKey: message.roomKey,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
retryQueue.append(updatedMessage)
}
} else if let room = message.room, let roomKeyData = message.roomKey {
// For room messages, check if we have peers in the room
} else if let channel = message.channel, let channelKeyData = message.channelKey {
// For channel messages, check if we have peers in the channel
if !connectedPeers.isEmpty {
// Recreate SymmetricKey from data
let roomKey = SymmetricKey(data: roomKeyData)
meshService.sendEncryptedRoomMessage(
let channelKey = SymmetricKey(data: channelKeyData)
meshService.sendEncryptedChannelMessage(
message.content,
mentions: message.mentions ?? [],
room: room,
roomKey: roomKey
channel: channel,
channelKey: channelKey
)
} else {
// No peers connected, keep in queue
@@ -150,11 +150,11 @@ class MessageRetryService {
id: message.id,
content: message.content,
mentions: message.mentions,
room: message.room,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
roomKey: message.roomKey,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
@@ -166,7 +166,7 @@ class MessageRetryService {
meshService.sendMessage(
message.content,
mentions: message.mentions ?? [],
room: message.room
channel: message.channel
)
} else {
// No peers connected, keep in queue
@@ -175,11 +175,11 @@ class MessageRetryService {
id: message.id,
content: message.content,
mentions: message.mentions,
room: message.room,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
roomKey: message.roomKey,
channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
+3 -3
View File
@@ -61,7 +61,7 @@ class NotificationService {
}
func sendMentionNotification(from sender: String, message: String) {
let title = "Mentioned by \(sender)"
let title = "@🫵 you were mentioned by \(sender)"
let body = message
let identifier = "mention-\(UUID().uuidString)"
@@ -69,7 +69,7 @@ class NotificationService {
}
func sendPrivateMessageNotification(from sender: String, message: String) {
let title = "Private message from \(sender)"
let title = "🔒 private message from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
@@ -83,4 +83,4 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
}
}
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -65,11 +65,11 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users")
FeatureRow(icon: "number", title: "Rooms",
description: "Create #rooms for topic-based conversations")
FeatureRow(icon: "number", title: "Channels",
description: "Create #channels for topic-based conversations")
FeatureRow(icon: "lock.fill", title: "Password Rooms",
description: "Secure rooms with passwords and AES encryption")
FeatureRow(icon: "lock.fill", title: "Password Channels",
description: "Secure channels with passwords and AES encryption")
}
// Privacy
@@ -92,10 +92,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header")
Text("• Swipe left or tap room name for sidebar")
Text("• Swipe left or tap channel name for sidebar")
Text("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone")
Text("• Use #roomname to create/join rooms")
Text("• Use #channelname to create/join channels")
Text("• Triple-tap the logo for panic mode")
}
.font(.system(size: 14, design: .monospaced))
@@ -113,7 +113,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)")
Text("Rooms: Password-protected with key commitments")
Text("Channels: Password-protected with key commitments")
Text("Storage: Keychain for passwords, encrypted retention")
}
.font(.system(size: 14, design: .monospaced))
@@ -171,11 +171,11 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users")
FeatureRow(icon: "number", title: "Rooms",
description: "Create #rooms for topic-based conversations")
FeatureRow(icon: "number", title: "Channels",
description: "Create #channels for topic-based conversations")
FeatureRow(icon: "lock.fill", title: "Password Rooms",
description: "Secure rooms with passwords and AES encryption")
FeatureRow(icon: "lock.fill", title: "Password Channels",
description: "Secure channels with passwords and AES encryption")
}
// Privacy
@@ -198,10 +198,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header")
Text("• Swipe left or tap room name for sidebar")
Text("• Swipe left or tap channel name for sidebar")
Text("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone")
Text("• Use #roomname to create/join rooms")
Text("• Use #channelname to create/join channels")
Text("• Triple-tap the logo for panic mode")
}
.font(.system(size: 14, design: .monospaced))
@@ -219,7 +219,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)")
Text("Rooms: Password-protected with key commitments")
Text("Channels: Password-protected with key commitments")
Text("Storage: Keychain for passwords, encrypted retention")
}
.font(.system(size: 14, design: .monospaced))
+348 -333
View File
@@ -19,7 +19,7 @@ struct ContentView: View {
@State private var sidebarDragOffset: CGFloat = 0
@State private var showAppInfo = false
@State private var showPasswordInput = false
@State private var passwordInputRoom: String? = nil
@State private var passwordInputChannel: String? = nil
@State private var passwordInput = ""
@State private var showPasswordPrompt = false
@State private var passwordPromptInput = ""
@@ -112,55 +112,6 @@ struct ContentView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
}
}
// Autocomplete overlay
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
GeometryReader { geometry in
VStack {
Spacer()
HStack {
// Calculate approximate position based on nickname length and @ position
let nicknameWidth: CGFloat = viewModel.selectedPrivateChatPeer != nil ? 90 : 80
let charWidth: CGFloat = 8.5 // Approximate width of monospace character
let atPosition = CGFloat(viewModel.autocompleteRange?.location ?? 0)
let offsetX = nicknameWidth + (atPosition * charWidth)
// Ensure offsetX is valid (not NaN or infinite)
let safeOffsetX = offsetX.isFinite ? offsetX : nicknameWidth
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text("@\(suggestion)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
}
.buttonStyle(.plain)
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
.frame(width: 150, alignment: .leading)
.offset(x: min(safeOffsetX, max(0, geometry.size.width - 180))) // Prevent going off-screen
.padding(.bottom, 45) // Position just above input
Spacer()
}
.padding(.horizontal, 12)
}
}
}
}
#if os(macOS)
.frame(minWidth: 600, minHeight: 400)
@@ -168,34 +119,34 @@ struct ContentView: View {
.sheet(isPresented: $showAppInfo) {
AppInfoView()
}
.alert("Set Room Password", isPresented: $showPasswordInput) {
.alert("Set Channel Password", isPresented: $showPasswordInput) {
SecureField("Password", text: $passwordInput)
Button("Cancel", role: .cancel) {
passwordInput = ""
passwordInputRoom = nil
passwordInputChannel = nil
}
Button("Set Password") {
if let room = passwordInputRoom, !passwordInput.isEmpty {
viewModel.setRoomPassword(passwordInput, for: room)
if let channel = passwordInputChannel, !passwordInput.isEmpty {
viewModel.setChannelPassword(passwordInput, for: channel)
passwordInput = ""
passwordInputRoom = nil
passwordInputChannel = nil
}
}
} message: {
Text("Enter a password to protect \(passwordInputRoom ?? "room"). Others will need this password to read messages.")
Text("Enter a password to protect \(passwordInputChannel ?? "channel"). Others will need this password to read messages.")
}
.alert("Enter Room Password", isPresented: Binding(
.alert("Enter Channel Password", isPresented: Binding(
get: { viewModel.showPasswordPrompt },
set: { viewModel.showPasswordPrompt = $0 }
)) {
SecureField("Password", text: $passwordPromptInput)
Button("Cancel", role: .cancel) {
passwordPromptInput = ""
viewModel.passwordPromptRoom = nil
viewModel.passwordPromptChannel = nil
}
Button("Join") {
if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty {
let success = viewModel.joinRoom(room, password: passwordPromptInput)
if let channel = viewModel.passwordPromptChannel, !passwordPromptInput.isEmpty {
let success = viewModel.joinChannel(channel, password: passwordPromptInput)
if success {
passwordPromptInput = ""
} else {
@@ -206,7 +157,7 @@ struct ContentView: View {
}
}
} message: {
Text("Room \(viewModel.passwordPromptRoom ?? "") is password protected. Enter the password to join.")
Text("Channel \(viewModel.passwordPromptChannel ?? "") is password protected. Enter the password to join.")
}
.alert("Wrong Password", isPresented: $showPasswordError) {
Button("OK", role: .cancel) { }
@@ -256,10 +207,10 @@ struct ContentView: View {
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
} else if let currentRoom = viewModel.currentRoom {
// Room header
} else if let currentChannel = viewModel.currentChannel {
// Channel header
Button(action: {
viewModel.switchToRoom(nil)
viewModel.switchToChannel(nil)
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
@@ -280,14 +231,14 @@ struct ContentView: View {
}
}) {
HStack(spacing: 6) {
if viewModel.passwordProtectedRooms.contains(currentRoom) {
if viewModel.passwordProtectedChannels.contains(currentChannel) {
Image(systemName: "lock.fill")
.font(.system(size: 14))
.foregroundColor(Color.orange)
}
Text("room: \(currentRoom)")
Text("channel: \(currentChannel)")
.font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.orange : Color.blue)
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue)
}
}
.buttonStyle(.plain)
@@ -297,48 +248,48 @@ struct ContentView: View {
HStack(spacing: 8) {
// Show retention indicator for all users
if viewModel.retentionEnabledRooms.contains(currentRoom) {
if viewModel.retentionEnabledChannels.contains(currentChannel) {
Image(systemName: "bookmark.fill")
.font(.system(size: 16))
.foregroundColor(Color.yellow)
.help("Messages in this room are being saved locally")
.help("Messages in this channel are being saved locally")
}
// Save button - only for room owner
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
// Save button - only for channel owner
if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: {
viewModel.sendMessage("/save")
}) {
Image(systemName: viewModel.retentionEnabledRooms.contains(currentRoom) ? "bookmark.slash" : "bookmark")
Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
.font(.system(size: 16))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
.help(viewModel.retentionEnabledRooms.contains(currentRoom) ? "Disable message retention" : "Enable message retention")
.help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
}
// Password button for room creator only
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
// Password button for channel creator only
if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
if viewModel.passwordProtectedRooms.contains(currentRoom) {
viewModel.removeRoomPassword(for: currentRoom)
if viewModel.passwordProtectedChannels.contains(currentChannel) {
viewModel.removeChannelPassword(for: currentChannel)
} else {
// Show password input
showPasswordInput = true
passwordInputRoom = currentRoom
passwordInputChannel = currentChannel
}
}) {
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.fill" : "lock")
Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock")
.font(.system(size: 16))
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor)
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
}
// Leave room button
// Leave channel button
Button(action: {
viewModel.leaveRoom(currentRoom)
viewModel.leaveChannel(currentChannel)
}) {
Text("leave")
.font(.system(size: 12, design: .monospaced))
@@ -384,10 +335,10 @@ struct ContentView: View {
// People counter with unread indicator
HStack(spacing: 4) {
// Check for any unread room messages
let hasUnreadRoomMessages = viewModel.unreadRoomMessages.values.contains { $0 > 0 }
// Check for any unread channel messages
let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
if hasUnreadRoomMessages {
if hasUnreadChannelMessages {
Image(systemName: "number")
.font(.system(size: 12))
.foregroundColor(Color.blue)
@@ -400,17 +351,26 @@ struct ContentView: View {
}
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
let roomCount = viewModel.joinedRooms.count
let statusText = if !viewModel.isConnected {
"alone :/"
} else if roomCount > 0 {
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")"
} else {
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")"
let channelCount = viewModel.joinedChannels.count
HStack(spacing: 4) {
// People icon with count
Image(systemName: "person.2.fill")
.font(.system(size: 11))
Text("\(otherPeersCount)")
.font(.system(size: 12, design: .monospaced))
// Channels icon with count (only if there are channels)
if channelCount > 0 {
Text("·")
.font(.system(size: 12, design: .monospaced))
Image(systemName: "square.split.2x2")
.font(.system(size: 11))
Text("\(channelCount)")
.font(.system(size: 12, design: .monospaced))
}
}
Text(statusText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
}
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
@@ -435,8 +395,8 @@ struct ContentView: View {
// Log what we're showing
// Removed debug logging
return msgs
} else if let currentRoom = viewModel.currentRoom {
return viewModel.getRoomMessages(currentRoom)
} else if let currentChannel = viewModel.currentChannel {
return viewModel.getChannelMessages(currentChannel)
} else {
return viewModel.messages
}
@@ -445,64 +405,29 @@ struct ContentView: View {
ForEach(messages, id: \.id) { message in
VStack(alignment: .leading, spacing: 4) {
// Check if current user is mentioned
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
let _ = message.mentions?.contains(viewModel.nickname) ?? false
if message.sender == "system" {
// System messages
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced))
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
// Regular messages with tappable sender name
HStack(alignment: .center, spacing: 0) {
// Timestamp
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
// Regular messages with natural text wrapping
HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
// Tappable sender name
if message.sender != viewModel.nickname {
Button(action: {
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
viewModel.startPrivateChat(with: peerID)
}
}) {
let senderColor = viewModel.getSenderColor(for: message, colorScheme: colorScheme)
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: 14, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.textSelection(.enabled)
}
Text(" ")
// Message content with clickable hashtags
MessageContentView(
message: message,
viewModel: viewModel,
colorScheme: colorScheme,
isMentioned: isMentioned
)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
.alignmentGuide(.firstTextBaseline) { _ in 12 }
}
Spacer()
}
}
}
@@ -559,45 +484,19 @@ struct ContentView: View {
private var inputView: some View {
VStack(spacing: 0) {
// Command suggestions
if showCommandSuggestions && !commandSuggestions.isEmpty {
// @mentions autocomplete
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
let baseCommands: [String: String] = [
"/j": "join or create a room",
"/rooms": "show all discovered rooms",
"/w": "see who's online",
"/m": "send private message",
"/clear": "clear chat messages"
]
let roomCommands: [String: String] = [
"/transfer": "transfer room ownership",
"/pass": "change room password",
"/save": "save room messages locally"
]
let commandDescriptions = viewModel.currentRoom != nil
? baseCommands.merging(roomCommands) { (_, new) in new }
: baseCommands
ForEach(commandSuggestions, id: \.self) { command in
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
// Replace current text with selected command
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
_ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
Text(command)
Text("@\(suggestion)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
Spacer()
if let description = commandDescriptions[command] {
Text(description)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
@@ -613,7 +512,79 @@ struct ContentView: View {
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
// Command suggestions
if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
// Define commands with aliases and syntax
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/clear"], nil, "clear chat messages"),
(["/hug"], "<nickname>", "send someone a warm hug"),
(["/j", "/join"], "<channel>", "join or create a channel"),
(["/m", "/msg"], "<nickname> [message]", "send private message"),
(["/channels"], nil, "show all discovered channels"),
(["/slap"], "<nickname>", "slap someone with a trout"),
(["/w"], nil, "see who's online")
]
let channelCommandInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/pass"], "[password]", "change channel password"),
(["/save"], nil, "save channel messages locally"),
(["/transfer"], "<nickname>", "transfer channel ownership")
]
// Build the display
let allCommands = viewModel.currentChannel != nil
? commandInfo + channelCommandInfo
: commandInfo
// Show matching commands
ForEach(commandSuggestions, id: \.self) { command in
// Find the command info for this suggestion
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
Button(action: {
// Replace current text with selected command
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
}) {
HStack {
// Show all aliases together
Text(info.commands.joined(separator: ", "))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
// Show syntax if any
if let syntax = info.syntax {
Text(syntax)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Spacer()
// Show description
Text(info.description)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
}
HStack(alignment: .center, spacing: 4) {
@@ -624,7 +595,7 @@ struct ContentView: View {
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
} else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
} else if let currentChannel = viewModel.currentChannel, viewModel.passwordProtectedChannels.contains(currentChannel) {
Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
@@ -654,24 +625,46 @@ struct ContentView: View {
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
var commandDescriptions = [
("/j", "join or create a room"),
("/rooms", "show all discovered rooms"),
("/w", "see who's online"),
("/clear", "clear chat messages"),
("/hug", "send someone a warm hug"),
("/j", "join or create a channel"),
("/m", "send private message"),
("/clear", "clear chat messages")
("/channels", "show all discovered channels"),
("/slap", "slap someone with a trout"),
("/w", "see who's online")
]
// Add room-specific commands if in a room
if viewModel.currentRoom != nil {
commandDescriptions.append(("/transfer", "transfer room ownership"))
commandDescriptions.append(("/pass", "change room password"))
commandDescriptions.append(("/save", "save room messages locally"))
// 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"))
}
let input = newValue.lowercased()
// Map of aliases to primary commands
let aliases: [String: String] = [
"/join": "/j",
"/msg": "/m"
]
// Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
// Also check if input matches an alias
for (alias, primary) in aliases {
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
if commandDescriptions.contains(where: { $0.0 == primary }) {
commandSuggestions.append(primary)
}
}
}
// Remove duplicates and sort
commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
@@ -686,7 +679,7 @@ struct ContentView: View {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
(viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? "")))
(viewModel.currentChannel != nil && viewModel.passwordProtectedChannels.contains(viewModel.currentChannel ?? "")))
? Color.orange : textColor)
}
.buttonStyle(.plain)
@@ -705,6 +698,128 @@ struct ContentView: View {
messageText = ""
}
@ViewBuilder
private var channelsSection: some View {
if !viewModel.joinedChannels.isEmpty {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Image(systemName: "square.split.2x2")
.font(.system(size: 10))
Text("CHANNELS")
.font(.system(size: 11, weight: .bold, design: .monospaced))
}
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
ForEach(Array(viewModel.joinedChannels).sorted(), id: \.self) { channel in
channelButton(for: channel)
}
}
}
}
@ViewBuilder
private func channelButton(for channel: String) -> some View {
Button(action: {
// Check if channel needs password and we don't have it
if viewModel.passwordProtectedChannels.contains(channel) && viewModel.channelKeys[channel] == nil {
// Need password
viewModel.passwordPromptChannel = channel
viewModel.showPasswordPrompt = true
} else {
// Can enter channel
viewModel.switchToChannel(channel)
withAnimation(.spring()) {
showSidebar = false
}
}
}) {
HStack {
// Lock icon for password protected channels
if viewModel.passwordProtectedChannels.contains(channel) {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
Text(channel)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(viewModel.currentChannel == channel ? Color.blue : textColor)
Spacer()
// Unread count
if let unreadCount = viewModel.unreadChannelMessages[channel], unreadCount > 0 {
Text("\(unreadCount)")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(backgroundColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
// Channel controls
if viewModel.currentChannel == channel {
channelControls(for: channel)
}
}
}
.buttonStyle(.plain)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(viewModel.currentChannel == channel ? backgroundColor.opacity(0.5) : Color.clear)
}
@ViewBuilder
private func channelControls(for channel: String) -> some View {
HStack(spacing: 4) {
// Password button for channel creator only
if viewModel.channelCreators[channel] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
if viewModel.passwordProtectedChannels.contains(channel) {
viewModel.removeChannelPassword(for: channel)
} else {
// Show password input
showPasswordInput = true
passwordInputChannel = channel
}
}) {
HStack(spacing: 2) {
Image(systemName: viewModel.passwordProtectedChannels.contains(channel) ? "lock.fill" : "lock")
.font(.system(size: 10))
}
.foregroundColor(viewModel.passwordProtectedChannels.contains(channel) ? backgroundColor : secondaryTextColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
// Leave button
Button(action: {
viewModel.leaveChannel(channel)
}) {
Text("leave channel")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
private var sidebarView: some View {
HStack(spacing: 0) {
// Grey vertical bar for visual continuity
@@ -715,7 +830,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
Text("connected")
Text("YOUR NETWORK")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
@@ -729,111 +844,10 @@ struct ContentView: View {
// Rooms and People list
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Joined Rooms section
if !viewModel.joinedRooms.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("ROOMS")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
Button(action: {
// Check if room needs password and we don't have it
if viewModel.passwordProtectedRooms.contains(room) && viewModel.roomKeys[room] == nil {
// Need password
viewModel.passwordPromptRoom = room
viewModel.showPasswordPrompt = true
} else {
// Can enter room
viewModel.switchToRoom(room)
withAnimation(.spring()) {
showSidebar = false
}
}
}) {
HStack {
// Lock icon for password protected rooms
if viewModel.passwordProtectedRooms.contains(room) {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
Text(room)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(viewModel.currentRoom == room ? Color.blue : textColor)
Spacer()
// Unread count
if let unreadCount = viewModel.unreadRoomMessages[room], unreadCount > 0 {
Text("\(unreadCount)")
.font(.system(size: 10, weight: .bold, design: .monospaced))
.foregroundColor(backgroundColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
// Room controls
if viewModel.currentRoom == room {
HStack(spacing: 4) {
// Password button for room creator only
if viewModel.roomCreators[room] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
if viewModel.passwordProtectedRooms.contains(room) {
viewModel.removeRoomPassword(for: room)
} else {
// Show password input
showPasswordInput = true
passwordInputRoom = room
}
}) {
HStack(spacing: 2) {
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
.font(.system(size: 10))
}
.foregroundColor(viewModel.passwordProtectedRooms.contains(room) ? backgroundColor : secondaryTextColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
// Leave button
Button(action: {
viewModel.leaveRoom(room)
}) {
Text("leave room")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(viewModel.currentRoom == room ? backgroundColor.opacity(0.5) : Color.clear)
}
.buttonStyle(.plain)
}
}
// Channels section
channelsSection
if !viewModel.joinedChannels.isEmpty {
Divider()
.padding(.vertical, 4)
}
@@ -841,16 +855,20 @@ struct ContentView: View {
// People section
VStack(alignment: .leading, spacing: 8) {
// Show appropriate header based on context
if let currentRoom = viewModel.currentRoom {
Text("IN \(currentRoom.uppercased())")
if let currentChannel = viewModel.currentChannel {
Text("IN \(currentChannel.uppercased())")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
} else if !viewModel.connectedPeers.isEmpty {
Text("PEOPLE")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: 10))
Text("PEOPLE")
.font(.system(size: 11, weight: .bold, design: .monospaced))
}
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
}
if viewModel.connectedPeers.isEmpty {
@@ -858,10 +876,10 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
} else if let currentRoom = viewModel.currentRoom,
let roomMemberIDs = viewModel.roomMembers[currentRoom],
roomMemberIDs.isEmpty {
Text("No one in this room yet")
} else if let currentChannel = viewModel.currentChannel,
let channelMemberIDs = viewModel.channelMembers[currentChannel],
channelMemberIDs.isEmpty {
Text("No one in this channel yet")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
@@ -870,17 +888,17 @@ struct ContentView: View {
let peerRSSI = viewModel.meshService.getPeerRSSI()
let myPeerID = viewModel.meshService.myPeerID
// Filter peers based on current room
// Filter peers based on current channel
let peersToShow: [String] = {
if let currentRoom = viewModel.currentRoom,
let roomMemberIDs = viewModel.roomMembers[currentRoom] {
// Show only peers who have sent messages to this room (including self)
if let currentChannel = viewModel.currentChannel,
let channelMemberIDs = viewModel.channelMembers[currentChannel] {
// Show only peers who have sent messages to this channel (including self)
// Start with room members who are also connected
var memberPeers = viewModel.connectedPeers.filter { roomMemberIDs.contains($0) }
// Start with channel members who are also connected
var memberPeers = viewModel.connectedPeers.filter { channelMemberIDs.contains($0) }
// Always include ourselves if we're a room member
if roomMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
// Always include ourselves if we're a channel member
if channelMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
memberPeers.append(myPeerID)
}
@@ -1014,33 +1032,30 @@ struct MessageContentView: View {
}
allMatches.sort { $0.range.location < $1.range.location }
// Build the text view with clickable hashtags
return HStack(spacing: 0) {
ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in
if segment.type == "hashtag" {
Button(action: {
_ = viewModel.joinRoom(segment.text)
}) {
Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.blue)
.underline()
.textSelection(.enabled)
}
.buttonStyle(.plain)
} else if segment.type == "mention" {
Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.orange)
.textSelection(.enabled)
} else {
Text(segment.text)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
}
// Build the text as a concatenated Text view for natural wrapping
let segments = buildTextSegments()
var result = Text("")
for segment in segments {
if segment.type == "hashtag" {
// Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
result = result + Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.blue)
.underline()
} else if segment.type == "mention" {
result = result + Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.orange)
} else {
result = result + Text(segment.text)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
}
return result
.textSelection(.enabled)
}
private func buildTextSegments() -> [(text: String, type: String)] {
+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