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 - **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 - **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 - **Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface - **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 - **Universal App**: Native support for iOS and macOS
- **Cover Traffic**: Timing obfuscation and dummy messages for enhanced privacy - **Cover Traffic**: Timing obfuscation and dummy messages for enhanced privacy
- **Emergency Wipe**: Triple-tap to instantly clear all data - **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 ### Basic Commands
- `/j #room` - Join or create a room - `/j #channel` - Join or create a channel
- `/m @user message` - Send a private message - `/m @user message` - Send a private message
- `/w` - List online users - `/w` - List online users
- `/rooms` - Show all discovered rooms - `/channels` - Show all discovered channels
- `/clear` - Clear chat messages - `/clear` - Clear chat messages
- `/pass [password]` - Set/change room password (owner only) - `/pass [password]` - Set/change channel password (owner only)
- `/transfer @user` - Transfer room ownership - `/transfer @user` - Transfer channel ownership
- `/save` - Toggle message retention for room (owner only) - `/save` - Toggle message retention for channel (owner only)
### Getting Started ### Getting Started
1. Launch bitchat on your device 1. Launch bitchat on your device
2. Set your nickname (or use the auto-generated one) 2. Set your nickname (or use the auto-generated one)
3. You'll automatically connect to nearby peers 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 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` - **Message Retention**: Owners can enable mandatory message saving with `/save`
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete) - **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
- **Ownership Transfer**: Pass control to trusted users with `/transfer` - **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 ### Encryption
- **Private Messages**: X25519 key exchange + AES-256-GCM 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 - **Digital Signatures**: Ed25519 for message authenticity
- **Forward Secrecy**: New key pairs generated each session - **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) 4. [Message Relay Protocol](#message-relay-protocol)
5. [Store and Forward Mechanism](#store-and-forward-mechanism) 5. [Store and Forward Mechanism](#store-and-forward-mechanism)
6. [Encryption and Security](#encryption-and-security) 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) 8. [Binary Protocol Specification](#binary-protocol-specification)
9. [Privacy Features](#privacy-features) 9. [Privacy Features](#privacy-features)
10. [Message Fragmentation](#message-fragmentation) 10. [Message Fragmentation](#message-fragmentation)
@@ -39,7 +39,7 @@ graph TB
subgraph "Application Layer" subgraph "Application Layer"
UI[Chat UI] UI[Chat UI]
CMD[Commands] CMD[Commands]
ROOM[Room Management] ROOM[Channel Management]
end end
subgraph "Service Layer" subgraph "Service Layer"
@@ -342,10 +342,10 @@ sequenceDiagram
### Encryption Layers ### Encryption Layers
1. **Private Messages**: X25519 key exchange + AES-256-GCM 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 3. **Digital Signatures**: Ed25519 for message authenticity
### Key Derivation for Rooms ### Key Derivation for Channels
<div align="center"> <div align="center">
@@ -355,7 +355,7 @@ graph LR
A --> K[256-bit Key] A --> K[256-bit Key]
K --> AES[AES-256-GCM] K --> AES[AES-256-GCM]
S[Salt - SHA256 of roomName] --> A S[Salt - SHA256 of channelName] --> A
I[Iterations - 10] --> A I[Iterations - 10] --> A
M[Memory - 64MB] --> A M[Memory - 64MB] --> A
T[Parallelism - 4] --> A T[Parallelism - 4] --> A
@@ -368,27 +368,27 @@ graph LR
</div> </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"> <div align="center">
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Discovery [*] --> Discovery
Discovery --> Joined: /j #room Discovery --> Joined: /j #channel
Joined --> PasswordPrompt: Room is protected Joined --> PasswordPrompt: Channel is protected
Joined --> Unlocked: Room is public Joined --> Unlocked: Channel is public
PasswordPrompt --> Unlocked: Correct password PasswordPrompt --> Unlocked: Correct password
PasswordPrompt --> PasswordPrompt: Wrong password PasswordPrompt --> PasswordPrompt: Wrong password
Unlocked --> [*]: Leave room Unlocked --> [*]: Leave channel
state Discovery { state Discovery {
[*] --> Scanning [*] --> Scanning
Scanning --> Found: Room activity detected Scanning --> Found: Channel activity detected
} }
state Unlocked { state Unlocked {
@@ -402,13 +402,13 @@ stateDiagram-v2
</div> </div>
### Room Features ### Channel Features
- **Hashtag naming**: Rooms identified by #roomname - **Hashtag naming**: Channels identified by #channelname
- **Password protection**: Optional encryption with shared passwords - **Password protection**: Optional encryption with shared passwords
- **Owner privileges**: Transfer ownership, change passwords - **Owner privileges**: Transfer ownership, change passwords
- **Message retention**: Owner-controlled mandatory retention - **Message retention**: Owner-controlled mandatory retention
- **Decentralized discovery**: Rooms discovered through usage - **Decentralized discovery**: Channels discovered through usage
## Binary Protocol Specification ## Binary Protocol Specification
@@ -467,8 +467,8 @@ classDiagram
| FRAGMENT_START | 0x05 | Start of fragmented message | | FRAGMENT_START | 0x05 | Start of fragmented message |
| FRAGMENT_CONTINUE | 0x06 | Continuation fragment | | FRAGMENT_CONTINUE | 0x06 | Continuation fragment |
| FRAGMENT_END | 0x07 | Final fragment | | FRAGMENT_END | 0x07 | Final fragment |
| ROOM_ANNOUNCE | 0x08 | Room status announcement | | ROOM_ANNOUNCE | 0x08 | Channel status announcement |
| ROOM_RETENTION | 0x09 | Room retention policy | | ROOM_RETENTION | 0x09 | Channel retention policy |
## Performance Optimizations ## Performance Optimizations
@@ -680,7 +680,7 @@ sequenceDiagram
alt Private Message alt Private Message
E->>E: Encrypt with X25519<br/>shared secret E->>E: Encrypt with X25519<br/>shared secret
else Room Message else Channel Message
E->>E: Encrypt with Argon2id<br/>derived key E->>E: Encrypt with Argon2id<br/>derived key
else Broadcast else Broadcast
E->>E: Sign with Ed25519 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. **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. **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 M->>G: Message for remote delivery
G->>G: Check opt-in status G->>G: Check opt-in status
alt Room allows bridging alt Channel allows bridging
G->>N: Convert to Nostr event G->>N: Convert to Nostr event
Note over N: Add bitchat metadata<br/>Maintain encryption Note over N: Add bitchat metadata<br/>Maintain encryption
N->>R: Publish event N->>R: Publish event
@@ -832,7 +832,7 @@ sequenceDiagram
Key considerations for maintaining bitchat's privacy model: Key considerations for maintaining bitchat's privacy model:
1. **Opt-in Only**: Network bridging disabled by default, requiring explicit user consent 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 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 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 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 - **Disaster Coordination**: Bridge local emergency mesh networks to coordinate broader relief efforts
- **Event Overflow**: Extend large gatherings beyond Bluetooth range while maintaining local clusters - **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 - **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. 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() var data = Data()
// Message format: // 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) // - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte // - ID length: 1 byte
// - ID: variable // - ID: variable
@@ -240,7 +240,7 @@ extension BitchatMessage {
// - Recipient nickname length + data // - Recipient nickname length + data
// - Sender peer ID length + data // - Sender peer ID length + data
// - Mentions array // - Mentions array
// - Room hashtag // - Channel hashtag
var flags: UInt8 = 0 var flags: UInt8 = 0
if isRelay { flags |= 0x01 } if isRelay { flags |= 0x01 }
@@ -249,7 +249,7 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 } if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 } if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
if room != nil { flags |= 0x40 } if channel != nil { flags |= 0x40 }
if isEncrypted { flags |= 0x80 } if isEncrypted { flags |= 0x80 }
data.append(flags) data.append(flags)
@@ -323,10 +323,10 @@ extension BitchatMessage {
} }
} }
// Room hashtag // Channel hashtag
if let room = room, let roomData = room.data(using: .utf8) { if let channel = channel, let channelData = channel.data(using: .utf8) {
data.append(UInt8(min(roomData.count, 255))) data.append(UInt8(min(channelData.count, 255)))
data.append(roomData.prefix(255)) data.append(channelData.prefix(255))
} }
return data return data
@@ -354,7 +354,7 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0 let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0 let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0 let hasMentions = (flags & 0x20) != 0
let hasRoom = (flags & 0x40) != 0 let hasChannel = (flags & 0x40) != 0
let isEncrypted = (flags & 0x80) != 0 let isEncrypted = (flags & 0x80) != 0
// Timestamp // Timestamp
@@ -465,12 +465,12 @@ extension BitchatMessage {
} }
} }
// Room // Channel
var room: String? = nil var channel: String? = nil
if hasRoom && offset < dataCopy.count { if hasChannel && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1 let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count { 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 offset += length
} }
} }
@@ -486,7 +486,7 @@ extension BitchatMessage {
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeerID: senderPeerID, senderPeerID: senderPeerID,
mentions: mentions, mentions: mentions,
room: room, channel: channel,
encryptedContent: encryptedContent, encryptedContent: encryptedContent,
isEncrypted: isEncrypted isEncrypted: isEncrypted
) )
+13 -13
View File
@@ -72,8 +72,8 @@ enum MessageType: UInt8 {
case fragmentStart = 0x05 case fragmentStart = 0x05
case fragmentContinue = 0x06 case fragmentContinue = 0x06
case fragmentEnd = 0x07 case fragmentEnd = 0x07
case roomAnnounce = 0x08 // Announce password-protected room status case channelAnnounce = 0x08 // Announce password-protected channel status
case roomRetention = 0x09 // Announce room retention status case channelRetention = 0x09 // Announce channel retention status
case deliveryAck = 0x0A // Acknowledge message received case deliveryAck = 0x0A // Acknowledge message received
case deliveryStatusRequest = 0x0B // Request delivery status update case deliveryStatusRequest = 0x0B // Request delivery status update
case readReceipt = 0x0C // Message has been read/viewed case readReceipt = 0x0C // Message has been read/viewed
@@ -220,12 +220,12 @@ struct BitchatMessage: Codable, Equatable {
let recipientNickname: String? let recipientNickname: String?
let senderPeerID: String? let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames 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 encryptedContent: Data? // For password-protected rooms
let isEncrypted: Bool // Flag to indicate if content is encrypted let isEncrypted: Bool // Flag to indicate if content is encrypted
var deliveryStatus: DeliveryStatus? // Delivery tracking 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.id = id ?? UUID().uuidString
self.sender = sender self.sender = sender
self.content = content self.content = content
@@ -236,7 +236,7 @@ struct BitchatMessage: Codable, Equatable {
self.recipientNickname = recipientNickname self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID self.senderPeerID = senderPeerID
self.mentions = mentions self.mentions = mentions
self.room = room self.channel = channel
self.encryptedContent = encryptedContent self.encryptedContent = encryptedContent
self.isEncrypted = isEncrypted self.isEncrypted = isEncrypted
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
@@ -248,10 +248,10 @@ protocol BitchatDelegate: AnyObject {
func didConnectToPeer(_ peerID: String) func didConnectToPeer(_ peerID: String)
func didDisconnectFromPeer(_ peerID: String) func didDisconnectFromPeer(_ peerID: String)
func didUpdatePeerList(_ peers: [String]) func didUpdatePeerList(_ peers: [String])
func didReceiveRoomLeave(_ room: String, from peerID: String) func didReceiveChannelLeave(_ channel: String, from peerID: String)
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?)
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String?
// Optional method to check if a fingerprint belongs to a favorite peer // Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
@@ -268,19 +268,19 @@ extension BitchatDelegate {
return false return false
} }
func didReceiveRoomLeave(_ room: String, from peerID: String) { func didReceiveChannelLeave(_ channel: String, from peerID: String) {
// Default empty implementation // 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 // Default empty implementation
} }
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) { func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
// Default empty implementation // Default empty implementation
} }
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? { func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String? {
// Default returns nil (unable to decrypt) // Default returns nil (unable to decrypt)
return nil return nil
} }
+47 -47
View File
@@ -489,7 +489,7 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic 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 messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -506,7 +506,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: self.myPeerID, senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions, mentions: mentions.isEmpty ? nil : mentions,
room: room channel: channel
) )
if let messageData = message.toBinaryPayload() { 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 messageQueue.async { [weak self] in
guard let self = self else { return } 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( let packet = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: Data(self.myPeerID.utf8), senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast, // Broadcast to all recipientID: SpecialRecipients.broadcast, // Broadcast to all
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(room.utf8), // Room hashtag as payload payload: Data(channel.utf8), // Channel hashtag as payload
signature: nil, signature: nil,
ttl: 3 // Short TTL for leave notifications 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 messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Payload format: room|isProtected|creatorID|keyCommitment // Payload format: channel|isProtected|creatorID|keyCommitment
let protectedFlag = isProtected ? "1" : "0" let protectedFlag = isProtected ? "1" : "0"
let creator = creatorID ?? self.myPeerID let creator = creatorID ?? self.myPeerID
let commitment = keyCommitment ?? "" let commitment = keyCommitment ?? ""
let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)" let payload = "\(channel)|\(protectedFlag)|\(creator)|\(commitment)"
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.roomAnnounce.rawValue, type: MessageType.channelAnnounce.rawValue,
senderID: Data(self.myPeerID.utf8), senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast, recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8), payload: Data(payload.utf8),
signature: nil, signature: nil,
ttl: 5 // Allow wider propagation for room announcements ttl: 5 // Allow wider propagation for channel announcements
) )
self.broadcastPacket(packet) self.broadcastPacket(packet)
} }
} }
func sendRoomRetentionAnnouncement(_ room: String, enabled: Bool) { func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Payload format: room|enabled|creatorID // Payload format: channel|enabled|creatorID
let enabledFlag = enabled ? "1" : "0" let enabledFlag = enabled ? "1" : "0"
let payload = "\(room)|\(enabledFlag)|\(self.myPeerID)" let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.roomRetention.rawValue, type: MessageType.channelRetention.rawValue,
senderID: Data(self.myPeerID.utf8), senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast, recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8), payload: Data(payload.utf8),
signature: nil, signature: nil,
ttl: 5 // Allow wider propagation for room announcements ttl: 5 // Allow wider propagation for channel announcements
) )
self.broadcastPacket(packet) 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 messageQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -797,7 +797,7 @@ class BluetoothMeshService: NSObject {
// Debug logging removed // Debug logging removed
do { do {
let sealedBox = try AES.GCM.seal(contentData, using: roomKey) let sealedBox = try AES.GCM.seal(contentData, using: channelKey)
let encryptedData = sealedBox.combined! let encryptedData = sealedBox.combined!
// Create message with encrypted content // Create message with encrypted content
@@ -811,7 +811,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: self.myPeerID, senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions, mentions: mentions.isEmpty ? nil : mentions,
room: room, channel: channel,
encryptedContent: encryptedData, encryptedContent: encryptedData,
isEncrypted: true isEncrypted: true
) )
@@ -1244,7 +1244,7 @@ class BluetoothMeshService: NSObject {
MessageRetryService.shared.addMessageForRetry( MessageRetryService.shared.addMessageForRetry(
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: nil, recipientPeerID: nil,
recipientNickname: message.recipientNickname recipientNickname: message.recipientNickname
@@ -1298,24 +1298,24 @@ class BluetoothMeshService: NSObject {
if sentToPeripherals == 0 && sentToCentrals == 0 { if sentToPeripherals == 0 && sentToCentrals == 0 {
if packet.type == MessageType.message.rawValue, if packet.type == MessageType.message.rawValue,
let message = BitchatMessage.fromBinaryPayload(packet.payload) { let message = BitchatMessage.fromBinaryPayload(packet.payload) {
// For encrypted room messages, we need to preserve the room key // For encrypted channel messages, we need to preserve the channel key
var roomKeyData: Data? = nil var channelKeyData: Data? = nil
if let room = message.room, message.isEncrypted { if let channel = message.channel, message.isEncrypted {
// This is an encrypted room message // This is an encrypted channel message
if let viewModel = delegate as? ChatViewModel, if let viewModel = delegate as? ChatViewModel,
let roomKey = viewModel.roomKeys[room] { let channelKey = viewModel.channelKeys[channel] {
roomKeyData = roomKey.withUnsafeBytes { Data($0) } channelKeyData = channelKey.withUnsafeBytes { Data($0) }
} }
} }
MessageRetryService.shared.addMessageForRetry( MessageRetryService.shared.addMessageForRetry(
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: nil, recipientPeerID: nil,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
roomKey: roomKeyData channelKey: channelKeyData
) )
} }
} }
@@ -1430,11 +1430,11 @@ class BluetoothMeshService: NSObject {
peerNicknames[senderID] = message.sender peerNicknames[senderID] = message.sender
peerNicknamesLock.unlock() peerNicknamesLock.unlock()
// Handle encrypted room messages // Handle encrypted channel messages
var finalContent = message.content 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 // 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 finalContent = decryptedContent
} else { } else {
// Unable to decrypt - show placeholder // Unable to decrypt - show placeholder
@@ -1453,7 +1453,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: senderID, senderPeerID: senderID,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
encryptedContent: message.encryptedContent, encryptedContent: message.encryptedContent,
isEncrypted: message.isEncrypted isEncrypted: message.isEncrypted
) )
@@ -1467,10 +1467,10 @@ class BluetoothMeshService: NSObject {
self.delegate?.didReceiveMessage(messageWithPeerID) 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 viewModel = self.delegate as? ChatViewModel
let myNickname = viewModel?.nickname ?? self.myPeerID let myNickname = viewModel?.nickname ?? self.myPeerID
if let _ = message.room, if let _ = message.channel,
let mentions = message.mentions, let mentions = message.mentions,
(mentions.contains(myNickname) || self.activePeers.count < 10) { (mentions.contains(myNickname) || self.activePeers.count < 10) {
if let ack = DeliveryTracker.shared.generateAck( if let ack = DeliveryTracker.shared.generateAck(
@@ -1575,7 +1575,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: senderID, senderPeerID: senderID,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
deliveryStatus: nil // Will be set to .delivered in ChatViewModel deliveryStatus: nil // Will be set to .delivered in ChatViewModel
) )
@@ -1867,13 +1867,13 @@ class BluetoothMeshService: NSObject {
case .leave: case .leave:
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) { if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
// Check if payload contains a room hashtag // Check if payload contains a channel hashtag
if let room = String(data: packet.payload, encoding: .utf8), if let channel = String(data: packet.payload, encoding: .utf8),
room.hasPrefix("#") { channel.hasPrefix("#") {
// Room leave notification // Channel leave notification
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.didReceiveRoomLeave(room, from: senderID) self.delegate?.didReceiveChannelLeave(channel, from: senderID)
} }
// Relay if TTL > 0 // Relay if TTL > 0
@@ -1924,19 +1924,19 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket) self.broadcastPacket(relayPacket)
} }
case .roomAnnounce: case .channelAnnounce:
if let payloadStr = String(data: packet.payload, encoding: .utf8) { 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) let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 { if components.count >= 3 {
let room = components[0] let channel = components[0]
let isProtected = components[1] == "1" let isProtected = components[1] == "1"
let creatorID = components[2] let creatorID = components[2]
let keyCommitment = components.count >= 4 ? components[3] : nil let keyCommitment = components.count >= 4 ? components[3] : nil
DispatchQueue.main.async { 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 // Relay announcement
@@ -1977,18 +1977,18 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket) self.broadcastPacket(relayPacket)
} }
case .roomRetention: case .channelRetention:
if let payloadStr = String(data: packet.payload, encoding: .utf8) { 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) let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 { if components.count >= 3 {
let room = components[0] let channel = components[0]
let enabled = components[1] == "1" let enabled = components[1] == "1"
let creatorID = components[2] let creatorID = components[2]
DispatchQueue.main.async { DispatchQueue.main.async {
self.delegate?.didReceiveRoomRetentionAnnouncement(room, enabled: enabled, creatorID: creatorID) self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
} }
// Relay announcement // Relay announcement
+15 -15
View File
@@ -41,19 +41,19 @@ class DeliveryTracker {
let recipientID: String let recipientID: String
let recipientNickname: String let recipientNickname: String
let retryCount: Int let retryCount: Int
let isRoomMessage: Bool let isChannelMessage: Bool
let isFavorite: Bool let isFavorite: Bool
var ackedBy: Set<String> = [] // For tracking partial room delivery var ackedBy: Set<String> = [] // For tracking partial channel delivery
let expectedRecipients: Int // For room messages let expectedRecipients: Int // For channel messages
var timeoutTimer: Timer? var timeoutTimer: Timer?
var isTimedOut: Bool { var isTimedOut: Bool {
let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30) let timeout: TimeInterval = isFavorite ? 300 : (isChannelMessage ? 60 : 30)
return Date().timeIntervalSince(sentAt) > timeout return Date().timeIntervalSince(sentAt) > timeout
} }
var shouldRetry: Bool { 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) { func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
// Don't track broadcasts or certain message types // 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( let delivery = PendingDelivery(
@@ -78,7 +78,7 @@ class DeliveryTracker {
recipientID: recipientID, recipientID: recipientID,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
retryCount: 0, retryCount: 0,
isRoomMessage: message.room != nil, isChannelMessage: message.channel != nil,
isFavorite: isFavorite, isFavorite: isFavorite,
expectedRecipients: expectedRecipients, expectedRecipients: expectedRecipients,
timeoutTimer: nil timeoutTimer: nil
@@ -118,8 +118,8 @@ class DeliveryTracker {
// Cancel timeout timer // Cancel timeout timer
delivery.timeoutTimer?.invalidate() delivery.timeoutTimer?.invalidate()
if delivery.isRoomMessage { if delivery.isChannelMessage {
// Track partial delivery for room messages // Track partial delivery for channel messages
delivery.ackedBy.insert(ack.recipientID) delivery.ackedBy.insert(ack.recipientID)
pendingDeliveries[ack.originalMessageID] = delivery pendingDeliveries[ack.originalMessageID] = delivery
@@ -146,7 +146,7 @@ class DeliveryTracker {
guard message.senderPeerID != myPeerID else { return nil } guard message.senderPeerID != myPeerID else { return nil }
// Don't ACK broadcasts or system messages // 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 // Don't ACK if we've already sent an ACK for this message
guard !sentAckIDs.contains(message.id) else { return nil } guard !sentAckIDs.contains(message.id) else { return nil }
@@ -187,11 +187,11 @@ class DeliveryTracker {
return return
} }
let isFavorite = delivery.isFavorite let isFavorite = delivery.isFavorite
let isRoomMessage = delivery.isRoomMessage let isChannelMessage = delivery.isChannelMessage
pendingLock.unlock() pendingLock.unlock()
let timeout = isFavorite ? favoriteTimeout : let timeout = isFavorite ? favoriteTimeout :
(isRoomMessage ? roomMessageTimeout : privateMessageTimeout) (isChannelMessage ? roomMessageTimeout : privateMessageTimeout)
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.handleTimeout(messageID: messageID) self?.handleTimeout(messageID: messageID)
@@ -213,7 +213,7 @@ class DeliveryTracker {
} }
let shouldRetry = delivery.shouldRetry let shouldRetry = delivery.shouldRetry
let isRoomMessage = delivery.isRoomMessage let isChannelMessage = delivery.isChannelMessage
if shouldRetry { if shouldRetry {
pendingLock.unlock() pendingLock.unlock()
@@ -221,7 +221,7 @@ class DeliveryTracker {
retryDelivery(messageID: messageID) retryDelivery(messageID: messageID)
} else { } else {
// Mark as failed // 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) pendingDeliveries.removeValue(forKey: messageID)
pendingLock.unlock() pendingLock.unlock()
updateDeliveryStatus(messageID, status: .failed(reason: reason)) updateDeliveryStatus(messageID, status: .failed(reason: reason))
@@ -242,7 +242,7 @@ class DeliveryTracker {
recipientID: delivery.recipientID, recipientID: delivery.recipientID,
recipientNickname: delivery.recipientNickname, recipientNickname: delivery.recipientNickname,
retryCount: delivery.retryCount + 1, retryCount: delivery.retryCount + 1,
isRoomMessage: delivery.isRoomMessage, isChannelMessage: delivery.isChannelMessage,
isFavorite: delivery.isFavorite, isFavorite: delivery.isFavorite,
ackedBy: delivery.ackedBy, ackedBy: delivery.ackedBy,
expectedRecipients: delivery.expectedRecipients, expectedRecipients: delivery.expectedRecipients,
+11 -11
View File
@@ -17,24 +17,24 @@ class KeychainManager {
private init() {} private init() {}
// MARK: - Room Passwords // MARK: - Channel Passwords
func saveRoomPassword(_ password: String, for room: String) -> Bool { func saveChannelPassword(_ password: String, for channel: String) -> Bool {
let key = "room_\(room)" let key = "channel_\(channel)"
return save(password, forKey: key) return save(password, forKey: key)
} }
func getRoomPassword(for room: String) -> String? { func getChannelPassword(for channel: String) -> String? {
let key = "room_\(room)" let key = "channel_\(channel)"
return retrieve(forKey: key) return retrieve(forKey: key)
} }
func deleteRoomPassword(for room: String) -> Bool { func deleteChannelPassword(for channel: String) -> Bool {
let key = "room_\(room)" let key = "channel_\(channel)"
return delete(forKey: key) return delete(forKey: key)
} }
func getAllRoomPasswords() -> [String: String] { func getAllChannelPasswords() -> [String: String] {
var passwords: [String: String] = [:] var passwords: [String: String] = [:]
// Query all items // Query all items
@@ -56,11 +56,11 @@ class KeychainManager {
if status == errSecSuccess, let items = result as? [[String: Any]] { if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items { for item in items {
if let account = item[kSecAttrAccount as String] as? String, if let account = item[kSecAttrAccount as String] as? String,
account.hasPrefix("room_"), account.hasPrefix("channel_"),
let data = item[kSecValueData as String] as? Data, let data = item[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8) { let password = String(data: data, encoding: .utf8) {
let room = String(account.dropFirst(5)) // Remove "room_" prefix let channel = String(account.dropFirst(8)) // Remove "channel_" prefix
passwords[room] = password passwords[channel] = password
} }
} }
} }
+31 -31
View File
@@ -15,7 +15,7 @@ struct StoredMessage: Codable {
let senderPeerID: String? let senderPeerID: String?
let content: String let content: String
let timestamp: Date let timestamp: Date
let roomTag: String? let channelTag: String?
let isPrivate: Bool let isPrivate: Bool
let recipientPeerID: String? let recipientPeerID: String?
} }
@@ -25,7 +25,7 @@ class MessageRetentionService {
private let documentsDirectory: URL private let documentsDirectory: URL
private let messagesDirectory: 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 retentionDays = 7 // Messages retained for 7 days
private let encryptionKey: SymmetricKey private let encryptionKey: SymmetricKey
@@ -50,32 +50,32 @@ class MessageRetentionService {
cleanupOldMessages() cleanupOldMessages()
} }
// MARK: - Favorite Rooms Management // MARK: - Favorite Channels Management
func getFavoriteRooms() -> Set<String> { func getFavoriteChannels() -> Set<String> {
let rooms = UserDefaults.standard.stringArray(forKey: favoriteRoomsKey) ?? [] let channels = UserDefaults.standard.stringArray(forKey: favoriteChannelsKey) ?? []
return Set(rooms) return Set(channels)
} }
func toggleFavoriteRoom(_ room: String) -> Bool { func toggleFavoriteChannel(_ channel: String) -> Bool {
var favorites = getFavoriteRooms() var favorites = getFavoriteChannels()
if favorites.contains(room) { if favorites.contains(channel) {
favorites.remove(room) favorites.remove(channel)
// Clean up messages for this room // Clean up messages for this channel
deleteMessagesForRoom(room) deleteMessagesForChannel(channel)
} else { } else {
favorites.insert(room) favorites.insert(channel)
} }
UserDefaults.standard.set(Array(favorites), forKey: favoriteRoomsKey) UserDefaults.standard.set(Array(favorites), forKey: favoriteChannelsKey)
return favorites.contains(room) return favorites.contains(channel)
} }
// MARK: - Message Storage // MARK: - Message Storage
func saveMessage(_ message: BitchatMessage, forRoom room: String?) { func saveMessage(_ message: BitchatMessage, forChannel channel: String?) {
// Only save messages for favorite rooms // Only save messages for favorite channels
guard let room = room ?? message.room, guard let channel = channel ?? message.channel,
getFavoriteRooms().contains(room) else { getFavoriteChannels().contains(channel) else {
return return
} }
@@ -86,7 +86,7 @@ class MessageRetentionService {
senderPeerID: message.senderPeerID, senderPeerID: message.senderPeerID,
content: message.content, content: message.content,
timestamp: message.timestamp, timestamp: message.timestamp,
roomTag: message.room, channelTag: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: message.senderPeerID recipientPeerID: message.senderPeerID
) )
@@ -98,22 +98,22 @@ class MessageRetentionService {
guard let encryptedData = encrypt(messageData) else { return } guard let encryptedData = encrypt(messageData) else { return }
// Save to file // 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) let fileURL = messagesDirectory.appendingPathComponent(fileName)
try? encryptedData.write(to: fileURL) try? encryptedData.write(to: fileURL)
} }
func loadMessagesForRoom(_ room: String) -> [BitchatMessage] { func loadMessagesForChannel(_ channel: String) -> [BitchatMessage] {
guard getFavoriteRooms().contains(room) else { return [] } guard getFavoriteChannels().contains(channel) else { return [] }
var messages: [BitchatMessage] = [] var messages: [BitchatMessage] = []
do { do {
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil) 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), if let encryptedData = try? Data(contentsOf: fileURL),
let decryptedData = decrypt(encryptedData), let decryptedData = decrypt(encryptedData),
let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) { let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) {
@@ -128,7 +128,7 @@ class MessageRetentionService {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: storedMessage.senderPeerID, senderPeerID: storedMessage.senderPeerID,
mentions: nil, mentions: nil,
room: storedMessage.roomTag channel: storedMessage.channelTag
) )
messages.append(message) messages.append(message)
@@ -179,12 +179,12 @@ class MessageRetentionService {
} }
} }
func deleteMessagesForRoom(_ room: String) { func deleteMessagesForChannel(_ channel: String) {
do { do {
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil) 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) try? FileManager.default.removeItem(at: fileURL)
} }
} catch { } catch {
@@ -200,7 +200,7 @@ class MessageRetentionService {
} catch { } catch {
} }
// Clear favorite rooms // Clear favorite channels
UserDefaults.standard.removeObject(forKey: favoriteRoomsKey) UserDefaults.standard.removeObject(forKey: favoriteChannelsKey)
} }
} }
+19 -19
View File
@@ -14,11 +14,11 @@ struct RetryableMessage {
let id: String let id: String
let content: String let content: String
let mentions: [String]? let mentions: [String]?
let room: String? let channel: String?
let isPrivate: Bool let isPrivate: Bool
let recipientPeerID: String? let recipientPeerID: String?
let recipientNickname: String? let recipientNickname: String?
let roomKey: Data? let channelKey: Data?
let retryCount: Int let retryCount: Int
let maxRetries: Int = 3 let maxRetries: Int = 3
let nextRetryTime: Date let nextRetryTime: Date
@@ -51,11 +51,11 @@ class MessageRetryService {
func addMessageForRetry( func addMessageForRetry(
content: String, content: String,
mentions: [String]? = nil, mentions: [String]? = nil,
room: String? = nil, channel: String? = nil,
isPrivate: Bool = false, isPrivate: Bool = false,
recipientPeerID: String? = nil, recipientPeerID: String? = nil,
recipientNickname: String? = nil, recipientNickname: String? = nil,
roomKey: Data? = nil channelKey: Data? = nil
) { ) {
// Don't queue if we're at capacity // Don't queue if we're at capacity
guard retryQueue.count < maxQueueSize else { guard retryQueue.count < maxQueueSize else {
@@ -66,11 +66,11 @@ class MessageRetryService {
id: UUID().uuidString, id: UUID().uuidString,
content: content, content: content,
mentions: mentions, mentions: mentions,
room: room, channel: channel,
isPrivate: isPrivate, isPrivate: isPrivate,
recipientPeerID: recipientPeerID, recipientPeerID: recipientPeerID,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
roomKey: roomKey, channelKey: channelKey,
retryCount: 0, retryCount: 0,
nextRetryTime: Date().addingTimeInterval(retryInterval) nextRetryTime: Date().addingTimeInterval(retryInterval)
) )
@@ -122,26 +122,26 @@ class MessageRetryService {
id: message.id, id: message.id,
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID, recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
roomKey: message.roomKey, channelKey: message.channelKey,
retryCount: message.retryCount + 1, retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
) )
retryQueue.append(updatedMessage) retryQueue.append(updatedMessage)
} }
} else if let room = message.room, let roomKeyData = message.roomKey { } else if let channel = message.channel, let channelKeyData = message.channelKey {
// For room messages, check if we have peers in the room // For channel messages, check if we have peers in the channel
if !connectedPeers.isEmpty { if !connectedPeers.isEmpty {
// Recreate SymmetricKey from data // Recreate SymmetricKey from data
let roomKey = SymmetricKey(data: roomKeyData) let channelKey = SymmetricKey(data: channelKeyData)
meshService.sendEncryptedRoomMessage( meshService.sendEncryptedChannelMessage(
message.content, message.content,
mentions: message.mentions ?? [], mentions: message.mentions ?? [],
room: room, channel: channel,
roomKey: roomKey channelKey: channelKey
) )
} else { } else {
// No peers connected, keep in queue // No peers connected, keep in queue
@@ -150,11 +150,11 @@ class MessageRetryService {
id: message.id, id: message.id,
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID, recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
roomKey: message.roomKey, channelKey: message.channelKey,
retryCount: message.retryCount + 1, retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
) )
@@ -166,7 +166,7 @@ class MessageRetryService {
meshService.sendMessage( meshService.sendMessage(
message.content, message.content,
mentions: message.mentions ?? [], mentions: message.mentions ?? [],
room: message.room channel: message.channel
) )
} else { } else {
// No peers connected, keep in queue // No peers connected, keep in queue
@@ -175,11 +175,11 @@ class MessageRetryService {
id: message.id, id: message.id,
content: message.content, content: message.content,
mentions: message.mentions, mentions: message.mentions,
room: message.room, channel: message.channel,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID, recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
roomKey: message.roomKey, channelKey: message.channelKey,
retryCount: message.retryCount + 1, retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) 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) { func sendMentionNotification(from sender: String, message: String) {
let title = "Mentioned by \(sender)" let title = "@🫵 you were mentioned by \(sender)"
let body = message let body = message
let identifier = "mention-\(UUID().uuidString)" let identifier = "mention-\(UUID().uuidString)"
@@ -69,7 +69,7 @@ class NotificationService {
} }
func sendPrivateMessageNotification(from sender: String, message: String) { func sendPrivateMessageNotification(from sender: String, message: String) {
let title = "Private message from \(sender)" let title = "🔒 private message from \(sender)"
let body = message let body = message
let identifier = "private-\(UUID().uuidString)" let identifier = "private-\(UUID().uuidString)"
@@ -83,4 +83,4 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier) 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", FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users") description: "Use @nickname to notify specific users")
FeatureRow(icon: "number", title: "Rooms", FeatureRow(icon: "number", title: "Channels",
description: "Create #rooms for topic-based conversations") description: "Create #channels for topic-based conversations")
FeatureRow(icon: "lock.fill", title: "Password Rooms", FeatureRow(icon: "lock.fill", title: "Password Channels",
description: "Secure rooms with passwords and AES encryption") description: "Secure channels with passwords and AES encryption")
} }
// Privacy // Privacy
@@ -92,10 +92,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header") 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("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone") 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") Text("• Triple-tap the logo for panic mode")
} }
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -113,7 +113,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites") Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level") Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)") 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") Text("Storage: Keychain for passwords, encrypted retention")
} }
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -171,11 +171,11 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions", FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users") description: "Use @nickname to notify specific users")
FeatureRow(icon: "number", title: "Rooms", FeatureRow(icon: "number", title: "Channels",
description: "Create #rooms for topic-based conversations") description: "Create #channels for topic-based conversations")
FeatureRow(icon: "lock.fill", title: "Password Rooms", FeatureRow(icon: "lock.fill", title: "Password Channels",
description: "Secure rooms with passwords and AES encryption") description: "Secure channels with passwords and AES encryption")
} }
// Privacy // Privacy
@@ -198,10 +198,10 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header") 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("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone") 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") Text("• Triple-tap the logo for panic mode")
} }
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
@@ -219,7 +219,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites") Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level") Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)") 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") Text("Storage: Keychain for passwords, encrypted retention")
} }
.font(.system(size: 14, design: .monospaced)) .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 sidebarDragOffset: CGFloat = 0
@State private var showAppInfo = false @State private var showAppInfo = false
@State private var showPasswordInput = 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 passwordInput = ""
@State private var showPasswordPrompt = false @State private var showPasswordPrompt = false
@State private var passwordPromptInput = "" @State private var passwordPromptInput = ""
@@ -112,55 +112,6 @@ struct ContentView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset) .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) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
@@ -168,34 +119,34 @@ struct ContentView: View {
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
} }
.alert("Set Room Password", isPresented: $showPasswordInput) { .alert("Set Channel Password", isPresented: $showPasswordInput) {
SecureField("Password", text: $passwordInput) SecureField("Password", text: $passwordInput)
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
passwordInput = "" passwordInput = ""
passwordInputRoom = nil passwordInputChannel = nil
} }
Button("Set Password") { Button("Set Password") {
if let room = passwordInputRoom, !passwordInput.isEmpty { if let channel = passwordInputChannel, !passwordInput.isEmpty {
viewModel.setRoomPassword(passwordInput, for: room) viewModel.setChannelPassword(passwordInput, for: channel)
passwordInput = "" passwordInput = ""
passwordInputRoom = nil passwordInputChannel = nil
} }
} }
} message: { } 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 }, get: { viewModel.showPasswordPrompt },
set: { viewModel.showPasswordPrompt = $0 } set: { viewModel.showPasswordPrompt = $0 }
)) { )) {
SecureField("Password", text: $passwordPromptInput) SecureField("Password", text: $passwordPromptInput)
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
passwordPromptInput = "" passwordPromptInput = ""
viewModel.passwordPromptRoom = nil viewModel.passwordPromptChannel = nil
} }
Button("Join") { Button("Join") {
if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty { if let channel = viewModel.passwordPromptChannel, !passwordPromptInput.isEmpty {
let success = viewModel.joinRoom(room, password: passwordPromptInput) let success = viewModel.joinChannel(channel, password: passwordPromptInput)
if success { if success {
passwordPromptInput = "" passwordPromptInput = ""
} else { } else {
@@ -206,7 +157,7 @@ struct ContentView: View {
} }
} }
} message: { } 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) { .alert("Wrong Password", isPresented: $showPasswordError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
@@ -256,10 +207,10 @@ struct ContentView: View {
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else if let currentRoom = viewModel.currentRoom { } else if let currentChannel = viewModel.currentChannel {
// Room header // Channel header
Button(action: { Button(action: {
viewModel.switchToRoom(nil) viewModel.switchToChannel(nil)
}) { }) {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
@@ -280,14 +231,14 @@ struct ContentView: View {
} }
}) { }) {
HStack(spacing: 6) { HStack(spacing: 6) {
if viewModel.passwordProtectedRooms.contains(currentRoom) { if viewModel.passwordProtectedChannels.contains(currentChannel) {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
} }
Text("room: \(currentRoom)") Text("channel: \(currentChannel)")
.font(.system(size: 16, weight: .medium, design: .monospaced)) .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) .buttonStyle(.plain)
@@ -297,48 +248,48 @@ struct ContentView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
// Show retention indicator for all users // Show retention indicator for all users
if viewModel.retentionEnabledRooms.contains(currentRoom) { if viewModel.retentionEnabledChannels.contains(currentChannel) {
Image(systemName: "bookmark.fill") Image(systemName: "bookmark.fill")
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundColor(Color.yellow) .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 // Save button - only for channel owner
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID { if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: { Button(action: {
viewModel.sendMessage("/save") 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)) .font(.system(size: 16))
.foregroundColor(textColor) .foregroundColor(textColor)
} }
.buttonStyle(.plain) .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 // Password button for channel creator only
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID { if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: { Button(action: {
// Toggle password protection // Toggle password protection
if viewModel.passwordProtectedRooms.contains(currentRoom) { if viewModel.passwordProtectedChannels.contains(currentChannel) {
viewModel.removeRoomPassword(for: currentRoom) viewModel.removeChannelPassword(for: currentChannel)
} else { } else {
// Show password input // Show password input
showPasswordInput = true 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)) .font(.system(size: 16))
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor) .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
// Leave room button // Leave channel button
Button(action: { Button(action: {
viewModel.leaveRoom(currentRoom) viewModel.leaveChannel(currentChannel)
}) { }) {
Text("leave") Text("leave")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
@@ -384,10 +335,10 @@ struct ContentView: View {
// People counter with unread indicator // People counter with unread indicator
HStack(spacing: 4) { HStack(spacing: 4) {
// Check for any unread room messages // Check for any unread channel messages
let hasUnreadRoomMessages = viewModel.unreadRoomMessages.values.contains { $0 > 0 } let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
if hasUnreadRoomMessages { if hasUnreadChannelMessages {
Image(systemName: "number") Image(systemName: "number")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
@@ -400,17 +351,26 @@ struct ContentView: View {
} }
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
let roomCount = viewModel.joinedRooms.count let channelCount = viewModel.joinedChannels.count
let statusText = if !viewModel.isConnected {
"alone :/" HStack(spacing: 4) {
} else if roomCount > 0 { // People icon with count
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")" Image(systemName: "person.2.fill")
} else { .font(.system(size: 11))
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")" 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) .foregroundColor(viewModel.isConnected ? textColor : Color.red)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
} }
.onTapGesture { .onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
@@ -435,8 +395,8 @@ struct ContentView: View {
// Log what we're showing // Log what we're showing
// Removed debug logging // Removed debug logging
return msgs return msgs
} else if let currentRoom = viewModel.currentRoom { } else if let currentChannel = viewModel.currentChannel {
return viewModel.getRoomMessages(currentRoom) return viewModel.getChannelMessages(currentChannel)
} else { } else {
return viewModel.messages return viewModel.messages
} }
@@ -445,64 +405,29 @@ struct ContentView: View {
ForEach(messages, id: \.id) { message in ForEach(messages, id: \.id) { message in
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
// Check if current user is mentioned // 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" { if message.sender == "system" {
// System messages // System messages
Text(viewModel.formatMessage(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced)) .textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else { } else {
// Regular messages with tappable sender name // Regular messages with natural text wrapping
HStack(alignment: .center, spacing: 0) { HStack(alignment: .top, spacing: 0) {
// Timestamp // Single text view for natural wrapping
Text("[\(viewModel.formatTimestamp(message.timestamp))] ") Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.textSelection(.enabled) .textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
// Tappable sender name .frame(maxWidth: .infinity, alignment: .leading)
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
)
// Delivery status indicator for private messages // Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname, if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus { let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme) DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4) .padding(.leading, 4)
.alignmentGuide(.firstTextBaseline) { _ in 12 }
} }
Spacer()
} }
} }
} }
@@ -559,45 +484,19 @@ struct ContentView: View {
private var inputView: some View { private var inputView: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Command suggestions // @mentions autocomplete
if showCommandSuggestions && !commandSuggestions.isEmpty { if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
let baseCommands: [String: String] = [ ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
"/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
Button(action: { Button(action: {
// Replace current text with selected command _ = viewModel.completeNickname(suggestion, in: &messageText)
messageText = command + " "
showCommandSuggestions = false
commandSuggestions = []
}) { }) {
HStack { HStack {
Text(command) Text("@\(suggestion)")
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
.fontWeight(.medium) .fontWeight(.medium)
Spacer() Spacer()
if let description = commandDescriptions[command] {
Text(description)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 3) .padding(.vertical, 3)
@@ -613,7 +512,79 @@ struct ContentView: View {
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
) )
.padding(.horizontal, 12) .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) { HStack(alignment: .center, spacing: 4) {
@@ -624,7 +595,7 @@ struct ContentView: View {
.lineLimit(1) .lineLimit(1)
.fixedSize() .fixedSize()
.padding(.leading, 12) .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)> →") Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced)) .font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange) .foregroundColor(Color.orange)
@@ -654,24 +625,46 @@ struct ContentView: View {
if newValue.hasPrefix("/") && newValue.count >= 1 { if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list // Build context-aware command list
var commandDescriptions = [ var commandDescriptions = [
("/j", "join or create a room"), ("/clear", "clear chat messages"),
("/rooms", "show all discovered rooms"), ("/hug", "send someone a warm hug"),
("/w", "see who's online"), ("/j", "join or create a channel"),
("/m", "send private message"), ("/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 // Add channel-specific commands if in a channel
if viewModel.currentRoom != nil { if viewModel.currentChannel != nil {
commandDescriptions.append(("/transfer", "transfer room ownership")) commandDescriptions.append(("/pass", "change channel password"))
commandDescriptions.append(("/pass", "change room password")) commandDescriptions.append(("/save", "save channel messages locally"))
commandDescriptions.append(("/save", "save room messages locally")) commandDescriptions.append(("/transfer", "transfer channel ownership"))
} }
let input = newValue.lowercased() 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 commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) } .filter { $0.0.starts(with: input) }
.map { $0.0 } .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 showCommandSuggestions = !commandSuggestions.isEmpty
} else { } else {
showCommandSuggestions = false showCommandSuggestions = false
@@ -686,7 +679,7 @@ struct ContentView: View {
Image(systemName: "arrow.up.circle.fill") Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20)) .font(.system(size: 20))
.foregroundColor((viewModel.selectedPrivateChatPeer != nil || .foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
(viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? ""))) (viewModel.currentChannel != nil && viewModel.passwordProtectedChannels.contains(viewModel.currentChannel ?? "")))
? Color.orange : textColor) ? Color.orange : textColor)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -705,6 +698,128 @@ struct ContentView: View {
messageText = "" 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 { private var sidebarView: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Grey vertical bar for visual continuity // Grey vertical bar for visual continuity
@@ -715,7 +830,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height // Header - match main toolbar height
HStack { HStack {
Text("connected") Text("YOUR NETWORK")
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
@@ -729,111 +844,10 @@ struct ContentView: View {
// Rooms and People list // Rooms and People list
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// Joined Rooms section // Channels section
if !viewModel.joinedRooms.isEmpty { channelsSection
VStack(alignment: .leading, spacing: 6) {
Text("ROOMS") if !viewModel.joinedChannels.isEmpty {
.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)
}
}
Divider() Divider()
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@@ -841,16 +855,20 @@ struct ContentView: View {
// People section // People section
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// Show appropriate header based on context // Show appropriate header based on context
if let currentRoom = viewModel.currentRoom { if let currentChannel = viewModel.currentChannel {
Text("IN \(currentRoom.uppercased())") Text("IN \(currentChannel.uppercased())")
.font(.system(size: 11, weight: .semibold, design: .monospaced)) .font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} else if !viewModel.connectedPeers.isEmpty { } else if !viewModel.connectedPeers.isEmpty {
Text("PEOPLE") HStack(spacing: 4) {
.font(.system(size: 11, weight: .semibold, design: .monospaced)) Image(systemName: "person.2.fill")
.foregroundColor(secondaryTextColor) .font(.system(size: 10))
.padding(.horizontal, 12) Text("PEOPLE")
.font(.system(size: 11, weight: .bold, design: .monospaced))
}
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
} }
if viewModel.connectedPeers.isEmpty { if viewModel.connectedPeers.isEmpty {
@@ -858,10 +876,10 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
} else if let currentRoom = viewModel.currentRoom, } else if let currentChannel = viewModel.currentChannel,
let roomMemberIDs = viewModel.roomMembers[currentRoom], let channelMemberIDs = viewModel.channelMembers[currentChannel],
roomMemberIDs.isEmpty { channelMemberIDs.isEmpty {
Text("No one in this room yet") Text("No one in this channel yet")
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor) .foregroundColor(secondaryTextColor)
.padding(.horizontal) .padding(.horizontal)
@@ -870,17 +888,17 @@ struct ContentView: View {
let peerRSSI = viewModel.meshService.getPeerRSSI() let peerRSSI = viewModel.meshService.getPeerRSSI()
let myPeerID = viewModel.meshService.myPeerID let myPeerID = viewModel.meshService.myPeerID
// Filter peers based on current room // Filter peers based on current channel
let peersToShow: [String] = { let peersToShow: [String] = {
if let currentRoom = viewModel.currentRoom, if let currentChannel = viewModel.currentChannel,
let roomMemberIDs = viewModel.roomMembers[currentRoom] { let channelMemberIDs = viewModel.channelMembers[currentChannel] {
// Show only peers who have sent messages to this room (including self) // Show only peers who have sent messages to this channel (including self)
// Start with room members who are also connected // Start with channel members who are also connected
var memberPeers = viewModel.connectedPeers.filter { roomMemberIDs.contains($0) } var memberPeers = viewModel.connectedPeers.filter { channelMemberIDs.contains($0) }
// Always include ourselves if we're a room member // Always include ourselves if we're a channel member
if roomMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) { if channelMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
memberPeers.append(myPeerID) memberPeers.append(myPeerID)
} }
@@ -1014,33 +1032,30 @@ struct MessageContentView: View {
} }
allMatches.sort { $0.range.location < $1.range.location } allMatches.sort { $0.range.location < $1.range.location }
// Build the text view with clickable hashtags // Build the text as a concatenated Text view for natural wrapping
return HStack(spacing: 0) { let segments = buildTextSegments()
ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in var result = Text("")
if segment.type == "hashtag" {
Button(action: { for segment in segments {
_ = viewModel.joinRoom(segment.text) if segment.type == "hashtag" {
}) { // Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
Text(segment.text) result = result + Text(segment.text)
.font(.system(size: 14, weight: .semibold, design: .monospaced)) .font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(Color.blue) .foregroundColor(Color.blue)
.underline() .underline()
.textSelection(.enabled) } else if segment.type == "mention" {
} result = result + Text(segment.text)
.buttonStyle(.plain) .font(.system(size: 14, weight: .semibold, design: .monospaced))
} else if segment.type == "mention" { .foregroundColor(Color.orange)
Text(segment.text) } else {
.font(.system(size: 14, weight: .semibold, design: .monospaced)) result = result + Text(segment.text)
.foregroundColor(Color.orange) .font(.system(size: 14, design: .monospaced))
.textSelection(.enabled) .fontWeight(isMentioned ? .bold : .regular)
} else {
Text(segment.text)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
}
} }
} }
return result
.textSelection(.enabled)
} }
private func buildTextSegments() -> [(text: String, type: String)] { private func buildTextSegments() -> [(text: String, type: String)] {
+9 -9
View File
@@ -43,7 +43,7 @@ class BitchatMessageTests: XCTestCase {
} }
func testRoomMessage() { func testRoomMessage() {
let roomMessage = BitchatMessage( let channelMessage = BitchatMessage(
sender: "alice", sender: "alice",
content: "Hello #general", content: "Hello #general",
timestamp: Date(), timestamp: Date(),
@@ -53,21 +53,21 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "alice123", senderPeerID: "alice123",
mentions: nil, mentions: nil,
room: "#general" channel: "#general"
) )
guard let encoded = roomMessage.toBinaryPayload() else { guard let encoded = channelMessage.toBinaryPayload() else {
XCTFail("Failed to encode room message") XCTFail("Failed to encode channel message")
return return
} }
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else { guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
XCTFail("Failed to decode room message") XCTFail("Failed to decode channel message")
return return
} }
XCTAssertEqual(decoded.room, "#general") XCTAssertEqual(decoded.channel, "#general")
XCTAssertEqual(decoded.content, roomMessage.content) XCTAssertEqual(decoded.content, channelMessage.content)
} }
func testEncryptedRoomMessage() { func testEncryptedRoomMessage() {
@@ -83,7 +83,7 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "bob456", senderPeerID: "bob456",
mentions: nil, mentions: nil,
room: "#secret", channel: "#secret",
encryptedContent: encryptedData, encryptedContent: encryptedData,
isEncrypted: true isEncrypted: true
) )
@@ -100,7 +100,7 @@ class BitchatMessageTests: XCTestCase {
XCTAssertTrue(decoded.isEncrypted) XCTAssertTrue(decoded.isEncrypted)
XCTAssertEqual(decoded.encryptedContent, encryptedData) XCTAssertEqual(decoded.encryptedContent, encryptedData)
XCTAssertEqual(decoded.room, "#secret") XCTAssertEqual(decoded.channel, "#secret")
XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages
} }
@@ -1,5 +1,5 @@
// //
// PasswordProtectedRoomTests.swift // PasswordProtectedChannelTests.swift
// bitchatTests // bitchatTests
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
@@ -11,7 +11,7 @@ import CryptoKit
import CommonCrypto import CommonCrypto
@testable import bitchat @testable import bitchat
class PasswordProtectedRoomTests: XCTestCase { class PasswordProtectedChannelTests: XCTestCase {
var viewModel: ChatViewModel! var viewModel: ChatViewModel!
override func setUp() { override func setUp() {
@@ -23,22 +23,22 @@ class PasswordProtectedRoomTests: XCTestCase {
viewModel = ChatViewModel() viewModel = ChatViewModel()
// Ensure clean state // Ensure clean state
viewModel.passwordProtectedRooms.removeAll() viewModel.passwordProtectedChannels.removeAll()
viewModel.roomCreators.removeAll() viewModel.channelCreators.removeAll()
viewModel.roomPasswords.removeAll() viewModel.channelPasswords.removeAll()
viewModel.roomKeys.removeAll() viewModel.channelKeys.removeAll()
viewModel.joinedRooms.removeAll() viewModel.joinedChannels.removeAll()
viewModel.roomMembers.removeAll() viewModel.channelMembers.removeAll()
viewModel.roomMessages.removeAll() viewModel.channelMessages.removeAll()
} }
private func clearAllUserDefaults() { private func clearAllUserDefaults() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
defaults.removeObject(forKey: "bitchat_nickname") defaults.removeObject(forKey: "bitchat_nickname")
defaults.removeObject(forKey: "bitchat_joined_rooms") defaults.removeObject(forKey: "bitchat_joined_channels")
defaults.removeObject(forKey: "bitchat_password_protected_rooms") defaults.removeObject(forKey: "bitchat_password_protected_channels")
defaults.removeObject(forKey: "bitchat_room_creators") defaults.removeObject(forKey: "bitchat_channel_creators")
defaults.removeObject(forKey: "bitchat_room_passwords") defaults.removeObject(forKey: "bitchat_channel_passwords")
defaults.removeObject(forKey: "bitchat_favorite_peers") defaults.removeObject(forKey: "bitchat_favorite_peers")
defaults.synchronize() defaults.synchronize()
} }
@@ -53,100 +53,100 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Key Derivation Tests // MARK: - Password Key Derivation Tests
func testPasswordKeyDerivation() { func testPasswordKeyDerivation() {
// Same password and room should always produce same key // Same password and channel should always produce same key
let password = "secretPassword123" let password = "secretPassword123"
let roomName = "#testroom" let channelName = "#testchannel"
let key1 = deriveRoomKey(from: password, roomName: roomName) let key1 = deriveChannelKey(from: password, channelName: channelName)
let key2 = deriveRoomKey(from: password, roomName: roomName) let key2 = deriveChannelKey(from: password, channelName: channelName)
// Keys should be identical // 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() { func testDifferentPasswordsProduceDifferentKeys() {
let roomName = "#testroom" let channelName = "#testchannel"
let password1 = "password123" let password1 = "password123"
let password2 = "different456" let password2 = "different456"
let key1 = deriveRoomKey(from: password1, roomName: roomName) let key1 = deriveChannelKey(from: password1, channelName: channelName)
let key2 = deriveRoomKey(from: password2, roomName: roomName) let key2 = deriveChannelKey(from: password2, channelName: channelName)
XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys") XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys")
} }
func testDifferentRoomsProduceDifferentKeys() { func testDifferentChannelsProduceDifferentKeys() {
let password = "samePassword" let password = "samePassword"
let room1 = "#room1" let channel1 = "#channel1"
let room2 = "#room2" let channel2 = "#channel2"
let key1 = deriveRoomKey(from: password, roomName: room1) let key1 = deriveChannelKey(from: password, channelName: channel1)
let key2 = deriveRoomKey(from: password, roomName: room2) 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() { func testJoinUnprotectedChannel() {
let roomName = "#public" let channelName = "#public"
let success = viewModel.joinRoom(roomName) let success = viewModel.joinChannel(channelName)
XCTAssertTrue(success, "Should be able to join unprotected room") XCTAssertTrue(success, "Should be able to join unprotected channel")
XCTAssertTrue(viewModel.joinedRooms.contains(roomName)) XCTAssertTrue(viewModel.joinedChannels.contains(channelName))
XCTAssertEqual(viewModel.currentRoom, roomName) XCTAssertEqual(viewModel.currentChannel, channelName)
XCTAssertTrue(viewModel.roomMembers[roomName]?.contains(viewModel.meshService.myPeerID) ?? false) XCTAssertTrue(viewModel.channelMembers[channelName]?.contains(viewModel.meshService.myPeerID) ?? false)
} }
func testCreatePasswordProtectedRoom() { func testCreatePasswordProtectedChannel() {
let roomName = "#private" let channelName = "#private"
let password = "secret123" let password = "secret123"
// Join room first // Join channel first
let joinSuccess = viewModel.joinRoom(roomName) let joinSuccess = viewModel.joinChannel(channelName)
XCTAssertTrue(joinSuccess) XCTAssertTrue(joinSuccess)
// Set password // Set password
viewModel.setRoomPassword(password, for: roomName) viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName)) XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
XCTAssertNotNil(viewModel.roomKeys[roomName]) XCTAssertNotNil(viewModel.channelKeys[channelName])
XCTAssertEqual(viewModel.roomPasswords[roomName], password) XCTAssertEqual(viewModel.channelPasswords[channelName], password)
XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID) XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
} }
func testJoinPasswordProtectedEmptyRoom() { func testJoinPasswordProtectedEmptyChannel() {
let roomName = "#protected" let channelName = "#protected"
let password = "test123" let password = "test123"
// Simulate room being marked as password protected // Simulate channel being marked as password protected
viewModel.passwordProtectedRooms.insert(roomName) viewModel.passwordProtectedChannels.insert(channelName)
// Try to join with password - should be accepted tentatively for empty room // Try to join with password - should be accepted tentatively for empty channel
let success = viewModel.joinRoom(roomName, password: password) let success = viewModel.joinChannel(channelName, password: password)
XCTAssertTrue(success, "Should accept tentative access to empty password-protected room") XCTAssertTrue(success, "Should accept tentative access to empty password-protected channel")
XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key tentatively") XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key tentatively")
XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Should store password tentatively") XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Should store password tentatively")
// Should have a system message explaining tentative access // 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") } 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") XCTAssertTrue(hasSystemMessage, "Should add system message explaining tentative access")
} }
func testJoinPasswordProtectedRoomWithMessages() { func testJoinPasswordProtectedChannelWithMessages() {
let roomName = "#secure" let channelName = "#secure"
let correctPassword = "correct123" let correctPassword = "correct123"
let wrongPassword = "wrong456" let wrongPassword = "wrong456"
let testMessage = "Test encrypted message" let testMessage = "Test encrypted message"
// First, create the room and set password as creator // First, create the channel and set password as creator
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
viewModel.setRoomPassword(correctPassword, for: roomName) viewModel.setChannelPassword(correctPassword, for: channelName)
// Simulate an encrypted message in the room // Simulate an encrypted message in the channel
let key = viewModel.roomKeys[roomName]! let key = viewModel.channelKeys[channelName]!
guard let messageData = testMessage.data(using: .utf8) else { guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data") XCTFail("Failed to convert message to data")
return return
@@ -166,26 +166,26 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "alice123", senderPeerID: "alice123",
mentions: nil, mentions: nil,
room: roomName, channel: channelName,
encryptedContent: encryptedData, encryptedContent: encryptedData,
isEncrypted: true isEncrypted: true
) )
// Add to room messages // Add to channel messages
viewModel.roomMessages[roomName] = [encryptedMsg] viewModel.channelMessages[channelName] = [encryptedMsg]
// Clear keys to simulate another user // Clear keys to simulate another user
viewModel.roomKeys.removeValue(forKey: roomName) viewModel.channelKeys.removeValue(forKey: channelName)
viewModel.roomPasswords.removeValue(forKey: roomName) viewModel.channelPasswords.removeValue(forKey: channelName)
// Try to join with wrong password // 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") XCTAssertFalse(wrongSuccess, "Should reject wrong password")
// Try to join with correct 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") 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 { } catch {
XCTFail("Encryption failed: \(error)") XCTFail("Encryption failed: \(error)")
@@ -194,13 +194,13 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Verification Tests // MARK: - Password Verification Tests
func testEncryptDecryptRoomMessage() { func testEncryptDecryptChannelMessage() {
let roomName = "#crypto" let channelName = "#crypto"
let password = "cryptoKey" let password = "cryptoKey"
let testMessage = "This is a secret message" let testMessage = "This is a secret message"
// Derive key // Derive key
let key = deriveRoomKey(from: password, roomName: roomName) let key = deriveChannelKey(from: password, channelName: channelName)
// Encrypt // Encrypt
guard let messageData = testMessage.data(using: .utf8) else { guard let messageData = testMessage.data(using: .utf8) else {
@@ -213,8 +213,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined! let encryptedData = sealedBox.combined!
// Store key and decrypt // Store key and decrypt
viewModel.roomKeys[roomName] = key viewModel.channelKeys[channelName] = key
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName) let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName)
XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original") XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original")
} catch { } catch {
@@ -223,13 +223,13 @@ class PasswordProtectedRoomTests: XCTestCase {
} }
func testWrongPasswordFailsDecryption() { func testWrongPasswordFailsDecryption() {
let roomName = "#secure" let channelName = "#secure"
let correctPassword = "correct" let correctPassword = "correct"
let wrongPassword = "wrong" let wrongPassword = "wrong"
let testMessage = "Secret content" let testMessage = "Secret content"
// Encrypt with correct password // 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 { guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data") XCTFail("Failed to convert message to data")
@@ -241,8 +241,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined! let encryptedData = sealedBox.combined!
// Try to decrypt with wrong password // Try to decrypt with wrong password
let wrongKey = deriveRoomKey(from: wrongPassword, roomName: roomName) let wrongKey = deriveChannelKey(from: wrongPassword, channelName: channelName)
let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName, testKey: wrongKey) let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName, testKey: wrongKey)
XCTAssertNil(decrypted, "Wrong password should fail to decrypt") XCTAssertNil(decrypted, "Wrong password should fail to decrypt")
} catch { } catch {
@@ -250,52 +250,52 @@ class PasswordProtectedRoomTests: XCTestCase {
} }
} }
// MARK: - Room Creator Tests // MARK: - Channel Creator Tests
func testOnlyCreatorCanSetPassword() { func testOnlyCreatorCanSetPassword() {
let roomName = "#owned" let channelName = "#owned"
let password = "ownerOnly" let password = "ownerOnly"
// Join room (becomes creator) // Join channel (becomes creator)
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
// Set password as creator // Set password as creator
viewModel.setRoomPassword(password, for: roomName) viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName)) XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Simulate another user trying to set password // Simulate another user trying to set password
viewModel.roomCreators[roomName] = "otherUser123" viewModel.channelCreators[channelName] = "otherUser123"
viewModel.setRoomPassword("hackerPassword", for: roomName) viewModel.setChannelPassword("hackerPassword", for: channelName)
// Password should not change // 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() { func testCreatorCanRemovePassword() {
let roomName = "#changeable" let channelName = "#changeable"
let password = "temporary" let password = "temporary"
// Create protected room // Create protected channel
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
viewModel.setRoomPassword(password, for: roomName) viewModel.setChannelPassword(password, for: channelName)
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName)) XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Remove password // Remove password
viewModel.removeRoomPassword(for: roomName) viewModel.removeChannelPassword(for: channelName)
XCTAssertFalse(viewModel.passwordProtectedRooms.contains(roomName)) XCTAssertFalse(viewModel.passwordProtectedChannels.contains(channelName))
XCTAssertNil(viewModel.roomKeys[roomName]) XCTAssertNil(viewModel.channelKeys[channelName])
XCTAssertNil(viewModel.roomPasswords[roomName]) XCTAssertNil(viewModel.channelPasswords[channelName])
} }
// MARK: - Message Handling Tests // MARK: - Message Handling Tests
func testReceiveEncryptedMessageWithoutKey() { func testReceiveEncryptedMessageWithoutKey() {
let roomName = "#encrypted" let channelName = "#encrypted"
// Join room without password // Join channel without password
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
// Simulate receiving encrypted message // Simulate receiving encrypted message
let encryptedMessage = BitchatMessage( let encryptedMessage = BitchatMessage(
@@ -308,109 +308,109 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "alice123", senderPeerID: "alice123",
mentions: nil, mentions: nil,
room: roomName, channel: channelName,
encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data
isEncrypted: true isEncrypted: true
) )
viewModel.didReceiveMessage(encryptedMessage) viewModel.didReceiveMessage(encryptedMessage)
// Should mark room as password protected // Should mark channel as password protected
XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName)) XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Should add system message // Should add system message
let roomMessages = viewModel.roomMessages[roomName] ?? [] let channelMessages = viewModel.channelMessages[channelName] ?? []
let hasSystemMessage = roomMessages.contains { $0.sender == "system" && $0.content.contains("password protected") } let hasSystemMessage = channelMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
XCTAssertTrue(hasSystemMessage, "Should add system message about password protection") XCTAssertTrue(hasSystemMessage, "Should add system message about password protection")
} }
// MARK: - Command Tests // MARK: - Command Tests
func testJoinCommand() { func testJoinCommand() {
let input = "/join #testroom" let input = "/join #testchannel"
viewModel.sendMessage(input) viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#testroom")) XCTAssertTrue(viewModel.joinedChannels.contains("#testchannel"))
XCTAssertEqual(viewModel.currentRoom, "#testroom") XCTAssertEqual(viewModel.currentChannel, "#testchannel")
} }
func testJoinCommandAlias() { func testJoinCommandAlias() {
let input = "/j #quick" let input = "/j #quick"
viewModel.sendMessage(input) viewModel.sendMessage(input)
XCTAssertTrue(viewModel.joinedRooms.contains("#quick")) XCTAssertTrue(viewModel.joinedChannels.contains("#quick"))
XCTAssertEqual(viewModel.currentRoom, "#quick") XCTAssertEqual(viewModel.currentChannel, "#quick")
} }
func testInvalidRoomName() { func testInvalidChannelName() {
let input = "/j #invalid-room!" let input = "/j #invalid-channel!"
viewModel.sendMessage(input) viewModel.sendMessage(input)
XCTAssertFalse(viewModel.joinedRooms.contains("#invalid-room!")) XCTAssertFalse(viewModel.joinedChannels.contains("#invalid-channel!"))
// Should have system message about invalid name // 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) XCTAssertTrue(hasErrorMessage)
} }
// MARK: - Key Commitment Tests // MARK: - Key Commitment Tests
func testKeyCommitmentVerification() { func testKeyCommitmentVerification() {
let roomName = "#commitment" let channelName = "#commitment"
let password = "testpass123" let password = "testpass123"
// Join and set password // Join and set password
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
viewModel.setRoomPassword(password, for: roomName) viewModel.setChannelPassword(password, for: channelName)
// Verify key commitment was stored // 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 // Simulate another user with the stored commitment
let commitment = viewModel.roomKeyCommitments[roomName]! let commitment = viewModel.channelKeyCommitments[channelName]!
viewModel.roomKeys.removeValue(forKey: roomName) viewModel.channelKeys.removeValue(forKey: channelName)
viewModel.roomPasswords.removeValue(forKey: roomName) viewModel.channelPasswords.removeValue(forKey: channelName)
// Manually set the commitment as if received from network // Manually set the commitment as if received from network
viewModel.roomKeyCommitments[roomName] = commitment viewModel.channelKeyCommitments[channelName] = commitment
// Try with wrong password - should fail immediately // 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") XCTAssertFalse(wrongSuccess, "Should reject wrong password via commitment check")
// Try with correct password - should succeed // 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") XCTAssertTrue(correctSuccess, "Should accept correct password via commitment check")
} }
func testOwnershipTransfer() { func testOwnershipTransfer() {
let roomName = "#transfertest" let channelName = "#transfertest"
let password = "ownerpass" let password = "ownerpass"
// Create room and set password // Create channel and set password
let _ = viewModel.joinRoom(roomName) let _ = viewModel.joinChannel(channelName)
viewModel.setRoomPassword(password, for: roomName) viewModel.setChannelPassword(password, for: channelName)
// Verify creator is set // 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) // Simulate transfer (in real app would use /transfer command)
let newOwnerID = "newowner123" let newOwnerID = "newowner123"
viewModel.roomCreators[roomName] = newOwnerID viewModel.channelCreators[channelName] = newOwnerID
// Verify ownership changed // Verify ownership changed
XCTAssertEqual(viewModel.roomCreators[roomName], newOwnerID) XCTAssertEqual(viewModel.channelCreators[channelName], newOwnerID)
XCTAssertNotEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID) XCTAssertNotEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
} }
} }
// MARK: - Helper Extensions for Testing // MARK: - Helper Extensions for Testing
extension PasswordProtectedRoomTests { extension PasswordProtectedChannelTests {
// Helper method to derive room key for testing // Helper method to derive channel key for testing
// This duplicates the logic from ChatViewModel for testing purposes // This duplicates the logic from ChatViewModel for testing purposes
func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey { func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
let salt = roomName.data(using: .utf8)! let salt = channelName.data(using: .utf8)!
let iterations = 100000 let iterations = 100000
let keyLength = 32 let keyLength = 32