diff --git a/README.md b/README.md
index 97fb5bd1..c102dcea 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,11 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
-- **Room-Based Chats**: Topic-based group messaging with optional password protection
+- **Channel-Based Chats**: Topic-based group messaging with optional password protection
- **Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
-- **Message Retention**: Optional room-wide message saving controlled by room owners
+- **Message Retention**: Optional channel-wide message saving controlled by channel owners
- **Universal App**: Native support for iOS and macOS
- **Cover Traffic**: Timing obfuscation and dummy messages for enhanced privacy
- **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -62,26 +62,29 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
### Basic Commands
-- `/j #room` - Join or create a room
-- `/m @user message` - Send a private message
+- `/j #channel` - Join or create a channel
+- `/m @name message` - Send a private message
- `/w` - List online users
-- `/rooms` - Show all discovered rooms
+- `/channels` - Show all discovered channels
+- `/block @name` - Block a peer from messaging you
+- `/block` - List all blocked peers
+- `/unblock @name` - Unblock a peer
- `/clear` - Clear chat messages
-- `/pass [password]` - Set/change room password (owner only)
-- `/transfer @user` - Transfer room ownership
-- `/save` - Toggle message retention for room (owner only)
+- `/pass [password]` - Set/change channel password (owner only)
+- `/transfer @name` - Transfer channel ownership
+- `/save` - Toggle message retention for channel (owner only)
### Getting Started
1. Launch bitchat on your device
2. Set your nickname (or use the auto-generated one)
3. You'll automatically connect to nearby peers
-4. Join a room with `/j #general` or start chatting in public
+4. Join a channel with `/j #general` or start chatting in public
5. Messages relay through the mesh network to reach distant peers
-### Room Features
+### Channel Features
-- **Password Protection**: Room owners can set passwords with `/pass`
+- **Password Protection**: Channel owners can set passwords with `/pass`
- **Message Retention**: Owners can enable mandatory message saving with `/save`
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
@@ -90,7 +93,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
### Encryption
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
-- **Room Messages**: Argon2id password derivation + AES-256-GCM
+- **Channel Messages**: Argon2id password derivation + AES-256-GCM
- **Digital Signatures**: Ed25519 for message authenticity
- **Forward Secrecy**: New key pairs generated each session
diff --git a/WHITEPAPER.md b/WHITEPAPER.md
index d5c043d8..d44083a9 100644
--- a/WHITEPAPER.md
+++ b/WHITEPAPER.md
@@ -12,7 +12,7 @@ bitchat is a decentralized, peer-to-peer messaging application that operates ove
4. [Message Relay Protocol](#message-relay-protocol)
5. [Store and Forward Mechanism](#store-and-forward-mechanism)
6. [Encryption and Security](#encryption-and-security)
-7. [Room-Based Communication](#room-based-communication)
+7. [Channel-Based Communication](#channel-based-communication)
8. [Binary Protocol Specification](#binary-protocol-specification)
9. [Privacy Features](#privacy-features)
10. [Message Fragmentation](#message-fragmentation)
@@ -39,7 +39,7 @@ graph TB
subgraph "Application Layer"
UI[Chat UI]
CMD[Commands]
- ROOM[Room Management]
+ ROOM[Channel Management]
end
subgraph "Service Layer"
@@ -342,10 +342,10 @@ sequenceDiagram
### Encryption Layers
1. **Private Messages**: X25519 key exchange + AES-256-GCM
-2. **Room Messages**: Password-derived keys using Argon2id
+2. **Channel Messages**: Password-derived keys using Argon2id
3. **Digital Signatures**: Ed25519 for message authenticity
-### Key Derivation for Rooms
+### Key Derivation for Channels
@@ -355,7 +355,7 @@ graph LR
A --> K[256-bit Key]
K --> AES[AES-256-GCM]
- S[Salt - SHA256 of roomName] --> A
+ S[Salt - SHA256 of channelName] --> A
I[Iterations - 10] --> A
M[Memory - 64MB] --> A
T[Parallelism - 4] --> A
@@ -368,27 +368,27 @@ graph LR
-## 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
```mermaid
stateDiagram-v2
[*] --> Discovery
- Discovery --> Joined: /j #room
- Joined --> PasswordPrompt: Room is protected
- Joined --> Unlocked: Room is public
+ Discovery --> Joined: /j #channel
+ Joined --> PasswordPrompt: Channel is protected
+ Joined --> Unlocked: Channel is public
PasswordPrompt --> Unlocked: Correct password
PasswordPrompt --> PasswordPrompt: Wrong password
- Unlocked --> [*]: Leave room
+ Unlocked --> [*]: Leave channel
state Discovery {
[*] --> Scanning
- Scanning --> Found: Room activity detected
+ Scanning --> Found: Channel activity detected
}
state Unlocked {
@@ -402,13 +402,13 @@ stateDiagram-v2
-### Room Features
+### Channel Features
-- **Hashtag naming**: Rooms identified by #roomname
+- **Hashtag naming**: Channels identified by #channelname
- **Password protection**: Optional encryption with shared passwords
- **Owner privileges**: Transfer ownership, change passwords
- **Message retention**: Owner-controlled mandatory retention
-- **Decentralized discovery**: Rooms discovered through usage
+- **Decentralized discovery**: Channels discovered through usage
## Binary Protocol Specification
@@ -467,8 +467,8 @@ classDiagram
| FRAGMENT_START | 0x05 | Start of fragmented message |
| FRAGMENT_CONTINUE | 0x06 | Continuation fragment |
| FRAGMENT_END | 0x07 | Final fragment |
-| ROOM_ANNOUNCE | 0x08 | Room status announcement |
-| ROOM_RETENTION | 0x09 | Room retention policy |
+| ROOM_ANNOUNCE | 0x08 | Channel status announcement |
+| ROOM_RETENTION | 0x09 | Channel retention policy |
## Performance Optimizations
@@ -680,7 +680,7 @@ sequenceDiagram
alt Private Message
E->>E: Encrypt with X25519
shared secret
- else Room Message
+ else Channel Message
E->>E: Encrypt with Argon2id
derived key
else Broadcast
E->>E: Sign with Ed25519
@@ -797,7 +797,7 @@ graph TB
**2. Asynchronous Delivery**: Nostr's event-based model aligns well with bitchat's store-and-forward mechanism, enabling message delivery across time zones and sporadic connectivity.
-**3. Selective Sharing**: Users could opt-in to share specific rooms or conversations beyond the local mesh, maintaining privacy by default.
+**3. Selective Sharing**: Users could opt-in to share specific channels or conversations beyond the local mesh, maintaining privacy by default.
**4. Decentralized Architecture**: Nostr's relay model preserves bitchat's decentralization principles - no single point of failure or control.
@@ -813,7 +813,7 @@ sequenceDiagram
M->>G: Message for remote delivery
G->>G: Check opt-in status
- alt Room allows bridging
+ alt Channel allows bridging
G->>N: Convert to Nostr event
Note over N: Add bitchat metadata
Maintain encryption
N->>R: Publish event
@@ -832,7 +832,7 @@ sequenceDiagram
Key considerations for maintaining bitchat's privacy model:
1. **Opt-in Only**: Network bridging disabled by default, requiring explicit user consent
-2. **Room-Level Control**: Bridge permissions managed per room, not globally
+2. **Channel-Level Control**: Bridge permissions managed per channel, not globally
3. **Maintained Encryption**: Messages remain end-to-end encrypted when bridged
4. **Ephemeral Options**: Support for Nostr's ephemeral events (NIP-16) for temporary bridging
5. **Identity Isolation**: Generate separate Nostr keypairs unlinked to local peer identities
@@ -841,7 +841,7 @@ Key considerations for maintaining bitchat's privacy model:
- **Disaster Coordination**: Bridge local emergency mesh networks to coordinate broader relief efforts
- **Event Overflow**: Extend large gatherings beyond Bluetooth range while maintaining local clusters
-- **Checkpoint Sync**: Periodically sync specific rooms when internet is briefly available
+- **Checkpoint Sync**: Periodically sync specific channels when internet is briefly available
- **Cross-Community Bridges**: Connect related but geographically separated communities
This extension would be implemented as an optional module, ensuring the core bitchat system remains fully functional without any network dependencies. Users in pure offline environments would see no change, while those with selective connectivity could benefit from enhanced reach when desired.
diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift
index 3df5d9e3..b33a1b31 100644
--- a/bitchat/Protocols/BinaryProtocol.swift
+++ b/bitchat/Protocols/BinaryProtocol.swift
@@ -227,7 +227,7 @@ extension BitchatMessage {
var data = Data()
// Message format:
- // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasRoom, bit 7: isEncrypted)
+ // - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasChannel, bit 7: isEncrypted)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
@@ -240,7 +240,7 @@ extension BitchatMessage {
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
- // - Room hashtag
+ // - Channel hashtag
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
@@ -249,7 +249,7 @@ extension BitchatMessage {
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
- if room != nil { flags |= 0x40 }
+ if channel != nil { flags |= 0x40 }
if isEncrypted { flags |= 0x80 }
data.append(flags)
@@ -323,10 +323,10 @@ extension BitchatMessage {
}
}
- // Room hashtag
- if let room = room, let roomData = room.data(using: .utf8) {
- data.append(UInt8(min(roomData.count, 255)))
- data.append(roomData.prefix(255))
+ // Channel hashtag
+ if let channel = channel, let channelData = channel.data(using: .utf8) {
+ data.append(UInt8(min(channelData.count, 255)))
+ data.append(channelData.prefix(255))
}
return data
@@ -354,7 +354,7 @@ extension BitchatMessage {
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
- let hasRoom = (flags & 0x40) != 0
+ let hasChannel = (flags & 0x40) != 0
let isEncrypted = (flags & 0x80) != 0
// Timestamp
@@ -465,12 +465,12 @@ extension BitchatMessage {
}
}
- // Room
- var room: String? = nil
- if hasRoom && offset < dataCopy.count {
+ // Channel
+ var channel: String? = nil
+ if hasChannel && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
- room = String(data: dataCopy[offset.. String?
+ func didReceiveChannelLeave(_ channel: String, from peerID: String)
+ func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
+ func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?)
+ func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String?
// Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool
@@ -268,19 +268,19 @@ extension BitchatDelegate {
return false
}
- func didReceiveRoomLeave(_ room: String, from peerID: String) {
+ func didReceiveChannelLeave(_ channel: String, from peerID: String) {
// Default empty implementation
}
- func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
+ func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
// Default empty implementation
}
- func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
+ func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
// Default empty implementation
}
- func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? {
+ func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String? {
// Default returns nil (unable to decrypt)
return nil
}
diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift
index 9d0c5b53..ca1a6ef4 100644
--- a/bitchat/Services/BluetoothMeshService.swift
+++ b/bitchat/Services/BluetoothMeshService.swift
@@ -249,6 +249,11 @@ class BluetoothMeshService: NSObject {
return String(fingerprint)
}
+ // Public method to get peer's public key data
+ func getPeerPublicKey(_ peerID: String) -> Data? {
+ return encryptionService.getPeerIdentityKey(peerID)
+ }
+
override init() {
// Generate ephemeral peer ID for each session to prevent tracking
// Use random bytes instead of UUID for better anonymity
@@ -490,7 +495,9 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic
}
- func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
+ func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
+ // Defensive check for empty content
+ guard !content.isEmpty else { return }
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -508,7 +515,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
- room: room
+ channel: channel
)
if let messageData = message.toBinaryPayload() {
@@ -570,6 +577,9 @@ class BluetoothMeshService: NSObject {
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
+ // Defensive checks
+ guard !content.isEmpty, !recipientPeerID.isEmpty, !recipientNickname.isEmpty else { return }
+
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -628,7 +638,11 @@ class BluetoothMeshService: NSObject {
// Check if recipient is offline and cache if they're a favorite
- if !self.activePeers.contains(recipientPeerID) {
+ self.activePeersLock.lock()
+ let isRecipientOffline = !self.activePeers.contains(recipientPeerID)
+ self.activePeersLock.unlock()
+
+ if isRecipientOffline {
if let publicKeyData = self.encryptionService.getPeerIdentityKey(recipientPeerID) {
let fingerprint = self.getPublicKeyFingerprint(publicKeyData)
if self.delegate?.isFavorite(fingerprint: fingerprint) ?? false {
@@ -673,17 +687,17 @@ class BluetoothMeshService: NSObject {
}
}
- func sendRoomLeaveNotification(_ room: String) {
+ func sendChannelLeaveNotification(_ channel: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
- // Create a leave packet with room hashtag as payload
+ // Create a leave packet with channel hashtag as payload
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast, // Broadcast to all
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
- payload: Data(room.utf8), // Room hashtag as payload
+ payload: Data(channel.utf8), // Channel hashtag as payload
signature: nil,
ttl: 3 // Short TTL for leave notifications
)
@@ -758,53 +772,53 @@ class BluetoothMeshService: NSObject {
}
}
- func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
+ func announcePasswordProtectedChannel(_ channel: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
- // Payload format: room|isProtected|creatorID|keyCommitment
+ // Payload format: channel|isProtected|creatorID|keyCommitment
let protectedFlag = isProtected ? "1" : "0"
let creator = creatorID ?? self.myPeerID
let commitment = keyCommitment ?? ""
- let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)"
+ let payload = "\(channel)|\(protectedFlag)|\(creator)|\(commitment)"
let packet = BitchatPacket(
- type: MessageType.roomAnnounce.rawValue,
+ type: MessageType.channelAnnounce.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8),
signature: nil,
- ttl: 5 // Allow wider propagation for room announcements
+ ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
- func sendRoomRetentionAnnouncement(_ room: String, enabled: Bool) {
+ func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
- // Payload format: room|enabled|creatorID
+ // Payload format: channel|enabled|creatorID
let enabledFlag = enabled ? "1" : "0"
- let payload = "\(room)|\(enabledFlag)|\(self.myPeerID)"
+ let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
let packet = BitchatPacket(
- type: MessageType.roomRetention.rawValue,
+ type: MessageType.channelRetention.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8),
signature: nil,
- ttl: 5 // Allow wider propagation for room announcements
+ ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
- func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) {
+ func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -817,8 +831,11 @@ class BluetoothMeshService: NSObject {
// Debug logging removed
do {
- let sealedBox = try AES.GCM.seal(contentData, using: roomKey)
- let encryptedData = sealedBox.combined!
+ let sealedBox = try AES.GCM.seal(contentData, using: channelKey)
+ guard let encryptedData = sealedBox.combined else {
+ // Encryption failed to produce combined data
+ return
+ }
// Create message with encrypted content
let message = BitchatMessage(
@@ -832,7 +849,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
- room: room,
+ channel: channel,
encryptedContent: encryptedData,
isEncrypted: true
)
@@ -1267,7 +1284,7 @@ class BluetoothMeshService: NSObject {
MessageRetryService.shared.addMessageForRetry(
content: message.content,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname,
@@ -1328,24 +1345,24 @@ class BluetoothMeshService: NSObject {
// This is our own message that failed to send
if packet.type == MessageType.message.rawValue,
let message = BitchatMessage.fromBinaryPayload(packet.payload) {
- // For encrypted room messages, we need to preserve the room key
- var roomKeyData: Data? = nil
- if let room = message.room, message.isEncrypted {
- // This is an encrypted room message
+ // For encrypted channel messages, we need to preserve the channel key
+ var channelKeyData: Data? = nil
+ if let channel = message.channel, message.isEncrypted {
+ // This is an encrypted channel message
if let viewModel = delegate as? ChatViewModel,
- let roomKey = viewModel.roomKeys[room] {
- roomKeyData = roomKey.withUnsafeBytes { Data($0) }
+ let channelKey = viewModel.channelKeys[channel] {
+ channelKeyData = channelKey.withUnsafeBytes { Data($0) }
}
}
MessageRetryService.shared.addMessageForRetry(
content: message.content,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname,
- roomKey: roomKeyData,
+ channelKey: channelKeyData,
originalMessageID: message.id,
originalTimestamp: message.timestamp
)
@@ -1463,11 +1480,11 @@ class BluetoothMeshService: NSObject {
peerNicknames[senderID] = message.sender
peerNicknamesLock.unlock()
- // Handle encrypted room messages
+ // Handle encrypted channel messages
var finalContent = message.content
- if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent {
+ if message.isEncrypted, let channel = message.channel, let encryptedData = message.encryptedContent {
// Try to decrypt the content
- if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) {
+ if let decryptedContent = self.delegate?.decryptChannelMessage(encryptedData, channel: channel) {
finalContent = decryptedContent
} else {
// Unable to decrypt - show placeholder
@@ -1486,7 +1503,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: nil,
senderPeerID: senderID,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
encryptedContent: message.encryptedContent,
isEncrypted: message.isEncrypted
)
@@ -1500,10 +1517,10 @@ class BluetoothMeshService: NSObject {
self.delegate?.didReceiveMessage(messageWithPeerID)
}
- // Generate and send ACK for room messages if we're mentioned or it's a small room
+ // Generate and send ACK for channel messages if we're mentioned or it's a small channel
let viewModel = self.delegate as? ChatViewModel
let myNickname = viewModel?.nickname ?? self.myPeerID
- if let _ = message.room,
+ if let _ = message.channel,
let mentions = message.mentions,
(mentions.contains(myNickname) || self.activePeers.count < 10) {
if let ack = DeliveryTracker.shared.generateAck(
@@ -1608,7 +1625,7 @@ class BluetoothMeshService: NSObject {
recipientNickname: message.recipientNickname,
senderPeerID: senderID,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
deliveryStatus: nil // Will be set to .delivered in ChatViewModel
)
@@ -1898,13 +1915,13 @@ class BluetoothMeshService: NSObject {
case .leave:
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
- // Check if payload contains a room hashtag
- if let room = String(data: packet.payload, encoding: .utf8),
- room.hasPrefix("#") {
- // Room leave notification
+ // Check if payload contains a channel hashtag
+ if let channel = String(data: packet.payload, encoding: .utf8),
+ channel.hasPrefix("#") {
+ // Channel leave notification
DispatchQueue.main.async {
- self.delegate?.didReceiveRoomLeave(room, from: senderID)
+ self.delegate?.didReceiveChannelLeave(channel, from: senderID)
}
// Relay if TTL > 0
@@ -1955,19 +1972,19 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket)
}
- case .roomAnnounce:
+ case .channelAnnounce:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
- // Parse payload: room|isProtected|creatorID|keyCommitment
+ // Parse payload: channel|isProtected|creatorID|keyCommitment
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
- let room = components[0]
+ let channel = components[0]
let isProtected = components[1] == "1"
let creatorID = components[2]
let keyCommitment = components.count >= 4 ? components[3] : nil
DispatchQueue.main.async {
- self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
+ self.delegate?.didReceivePasswordProtectedChannelAnnouncement(channel, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
}
// Relay announcement
@@ -2008,18 +2025,18 @@ class BluetoothMeshService: NSObject {
self.broadcastPacket(relayPacket)
}
- case .roomRetention:
+ case .channelRetention:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
- // Parse payload: room|enabled|creatorID
+ // Parse payload: channel|enabled|creatorID
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
- let room = components[0]
+ let channel = components[0]
let enabled = components[1] == "1"
let creatorID = components[2]
DispatchQueue.main.async {
- self.delegate?.didReceiveRoomRetentionAnnouncement(room, enabled: enabled, creatorID: creatorID)
+ self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
}
// Relay announcement
diff --git a/bitchat/Services/DeliveryTracker.swift b/bitchat/Services/DeliveryTracker.swift
index a00f8683..9cb90acd 100644
--- a/bitchat/Services/DeliveryTracker.swift
+++ b/bitchat/Services/DeliveryTracker.swift
@@ -41,19 +41,19 @@ class DeliveryTracker {
let recipientID: String
let recipientNickname: String
let retryCount: Int
- let isRoomMessage: Bool
+ let isChannelMessage: Bool
let isFavorite: Bool
- var ackedBy: Set = [] // For tracking partial room delivery
- let expectedRecipients: Int // For room messages
+ var ackedBy: Set = [] // For tracking partial channel delivery
+ let expectedRecipients: Int // For channel messages
var timeoutTimer: Timer?
var isTimedOut: Bool {
- let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30)
+ let timeout: TimeInterval = isFavorite ? 300 : (isChannelMessage ? 60 : 30)
return Date().timeIntervalSince(sentAt) > timeout
}
var shouldRetry: Bool {
- return retryCount < 3 && isFavorite && !isRoomMessage
+ return retryCount < 3 && isFavorite && !isChannelMessage
}
}
@@ -69,7 +69,7 @@ class DeliveryTracker {
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
// Don't track broadcasts or certain message types
- guard message.isPrivate || message.room != nil else { return }
+ guard message.isPrivate || message.channel != nil else { return }
let delivery = PendingDelivery(
@@ -78,7 +78,7 @@ class DeliveryTracker {
recipientID: recipientID,
recipientNickname: recipientNickname,
retryCount: 0,
- isRoomMessage: message.room != nil,
+ isChannelMessage: message.channel != nil,
isFavorite: isFavorite,
expectedRecipients: expectedRecipients,
timeoutTimer: nil
@@ -118,8 +118,8 @@ class DeliveryTracker {
// Cancel timeout timer
delivery.timeoutTimer?.invalidate()
- if delivery.isRoomMessage {
- // Track partial delivery for room messages
+ if delivery.isChannelMessage {
+ // Track partial delivery for channel messages
delivery.ackedBy.insert(ack.recipientID)
pendingDeliveries[ack.originalMessageID] = delivery
@@ -146,7 +146,7 @@ class DeliveryTracker {
guard message.senderPeerID != myPeerID else { return nil }
// Don't ACK broadcasts or system messages
- guard message.isPrivate || message.room != nil else { return nil }
+ guard message.isPrivate || message.channel != nil else { return nil }
// Don't ACK if we've already sent an ACK for this message
guard !sentAckIDs.contains(message.id) else { return nil }
@@ -187,11 +187,11 @@ class DeliveryTracker {
return
}
let isFavorite = delivery.isFavorite
- let isRoomMessage = delivery.isRoomMessage
+ let isChannelMessage = delivery.isChannelMessage
pendingLock.unlock()
let timeout = isFavorite ? favoriteTimeout :
- (isRoomMessage ? roomMessageTimeout : privateMessageTimeout)
+ (isChannelMessage ? roomMessageTimeout : privateMessageTimeout)
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.handleTimeout(messageID: messageID)
@@ -213,7 +213,7 @@ class DeliveryTracker {
}
let shouldRetry = delivery.shouldRetry
- let isRoomMessage = delivery.isRoomMessage
+ let isChannelMessage = delivery.isChannelMessage
if shouldRetry {
pendingLock.unlock()
@@ -221,7 +221,7 @@ class DeliveryTracker {
retryDelivery(messageID: messageID)
} else {
// Mark as failed
- let reason = isRoomMessage ? "No response from room members" : "Message not delivered"
+ let reason = isChannelMessage ? "No response from channel members" : "Message not delivered"
pendingDeliveries.removeValue(forKey: messageID)
pendingLock.unlock()
updateDeliveryStatus(messageID, status: .failed(reason: reason))
@@ -242,7 +242,7 @@ class DeliveryTracker {
recipientID: delivery.recipientID,
recipientNickname: delivery.recipientNickname,
retryCount: delivery.retryCount + 1,
- isRoomMessage: delivery.isRoomMessage,
+ isChannelMessage: delivery.isChannelMessage,
isFavorite: delivery.isFavorite,
ackedBy: delivery.ackedBy,
expectedRecipients: delivery.expectedRecipients,
diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift
index c4be5ca0..541d0634 100644
--- a/bitchat/Services/KeychainManager.swift
+++ b/bitchat/Services/KeychainManager.swift
@@ -17,24 +17,24 @@ class KeychainManager {
private init() {}
- // MARK: - Room Passwords
+ // MARK: - Channel Passwords
- func saveRoomPassword(_ password: String, for room: String) -> Bool {
- let key = "room_\(room)"
+ func saveChannelPassword(_ password: String, for channel: String) -> Bool {
+ let key = "channel_\(channel)"
return save(password, forKey: key)
}
- func getRoomPassword(for room: String) -> String? {
- let key = "room_\(room)"
+ func getChannelPassword(for channel: String) -> String? {
+ let key = "channel_\(channel)"
return retrieve(forKey: key)
}
- func deleteRoomPassword(for room: String) -> Bool {
- let key = "room_\(room)"
+ func deleteChannelPassword(for channel: String) -> Bool {
+ let key = "channel_\(channel)"
return delete(forKey: key)
}
- func getAllRoomPasswords() -> [String: String] {
+ func getAllChannelPasswords() -> [String: String] {
var passwords: [String: String] = [:]
// Query all items
@@ -56,11 +56,11 @@ class KeychainManager {
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
if let account = item[kSecAttrAccount as String] as? String,
- account.hasPrefix("room_"),
+ account.hasPrefix("channel_"),
let data = item[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8) {
- let room = String(account.dropFirst(5)) // Remove "room_" prefix
- passwords[room] = password
+ let channel = String(account.dropFirst(8)) // Remove "channel_" prefix
+ passwords[channel] = password
}
}
}
diff --git a/bitchat/Services/MessageRetentionService.swift b/bitchat/Services/MessageRetentionService.swift
index 1e1654aa..5775146d 100644
--- a/bitchat/Services/MessageRetentionService.swift
+++ b/bitchat/Services/MessageRetentionService.swift
@@ -15,7 +15,7 @@ struct StoredMessage: Codable {
let senderPeerID: String?
let content: String
let timestamp: Date
- let roomTag: String?
+ let channelTag: String?
let isPrivate: Bool
let recipientPeerID: String?
}
@@ -25,13 +25,16 @@ class MessageRetentionService {
private let documentsDirectory: URL
private let messagesDirectory: URL
- private let favoriteRoomsKey = "bitchat.favoriteRooms"
+ private let favoriteChannelsKey = "bitchat.favoriteChannels"
private let retentionDays = 7 // Messages retained for 7 days
private let encryptionKey: SymmetricKey
private init() {
// Get documents directory
- documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+ guard let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
+ fatalError("Unable to access documents directory")
+ }
+ documentsDirectory = docsDir
messagesDirectory = documentsDirectory.appendingPathComponent("Messages", isDirectory: true)
// Create messages directory if it doesn't exist
@@ -50,32 +53,32 @@ class MessageRetentionService {
cleanupOldMessages()
}
- // MARK: - Favorite Rooms Management
+ // MARK: - Favorite Channels Management
- func getFavoriteRooms() -> Set {
- let rooms = UserDefaults.standard.stringArray(forKey: favoriteRoomsKey) ?? []
- return Set(rooms)
+ func getFavoriteChannels() -> Set {
+ let channels = UserDefaults.standard.stringArray(forKey: favoriteChannelsKey) ?? []
+ return Set(channels)
}
- func toggleFavoriteRoom(_ room: String) -> Bool {
- var favorites = getFavoriteRooms()
- if favorites.contains(room) {
- favorites.remove(room)
- // Clean up messages for this room
- deleteMessagesForRoom(room)
+ func toggleFavoriteChannel(_ channel: String) -> Bool {
+ var favorites = getFavoriteChannels()
+ if favorites.contains(channel) {
+ favorites.remove(channel)
+ // Clean up messages for this channel
+ deleteMessagesForChannel(channel)
} else {
- favorites.insert(room)
+ favorites.insert(channel)
}
- UserDefaults.standard.set(Array(favorites), forKey: favoriteRoomsKey)
- return favorites.contains(room)
+ UserDefaults.standard.set(Array(favorites), forKey: favoriteChannelsKey)
+ return favorites.contains(channel)
}
// MARK: - Message Storage
- func saveMessage(_ message: BitchatMessage, forRoom room: String?) {
- // Only save messages for favorite rooms
- guard let room = room ?? message.room,
- getFavoriteRooms().contains(room) else {
+ func saveMessage(_ message: BitchatMessage, forChannel channel: String?) {
+ // Only save messages for favorite channels
+ guard let channel = channel ?? message.channel,
+ getFavoriteChannels().contains(channel) else {
return
}
@@ -86,7 +89,7 @@ class MessageRetentionService {
senderPeerID: message.senderPeerID,
content: message.content,
timestamp: message.timestamp,
- roomTag: message.room,
+ channelTag: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.senderPeerID
)
@@ -98,22 +101,22 @@ class MessageRetentionService {
guard let encryptedData = encrypt(messageData) else { return }
// Save to file
- let fileName = "\(room)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
+ let fileName = "\(channel)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
let fileURL = messagesDirectory.appendingPathComponent(fileName)
try? encryptedData.write(to: fileURL)
}
- func loadMessagesForRoom(_ room: String) -> [BitchatMessage] {
- guard getFavoriteRooms().contains(room) else { return [] }
+ func loadMessagesForChannel(_ channel: String) -> [BitchatMessage] {
+ guard getFavoriteChannels().contains(channel) else { return [] }
var messages: [BitchatMessage] = []
do {
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
- let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
+ let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
- for fileURL in roomFiles {
+ for fileURL in channelFiles {
if let encryptedData = try? Data(contentsOf: fileURL),
let decryptedData = decrypt(encryptedData),
let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) {
@@ -128,7 +131,7 @@ class MessageRetentionService {
recipientNickname: nil,
senderPeerID: storedMessage.senderPeerID,
mentions: nil,
- room: storedMessage.roomTag
+ channel: storedMessage.channelTag
)
messages.append(message)
@@ -179,12 +182,12 @@ class MessageRetentionService {
}
}
- func deleteMessagesForRoom(_ room: String) {
+ func deleteMessagesForChannel(_ channel: String) {
do {
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
- let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
+ let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
- for fileURL in roomFiles {
+ for fileURL in channelFiles {
try? FileManager.default.removeItem(at: fileURL)
}
} catch {
@@ -200,7 +203,7 @@ class MessageRetentionService {
} catch {
}
- // Clear favorite rooms
- UserDefaults.standard.removeObject(forKey: favoriteRoomsKey)
+ // Clear favorite channels
+ UserDefaults.standard.removeObject(forKey: favoriteChannelsKey)
}
}
diff --git a/bitchat/Services/MessageRetryService.swift b/bitchat/Services/MessageRetryService.swift
index 48a7d4c8..53c691eb 100644
--- a/bitchat/Services/MessageRetryService.swift
+++ b/bitchat/Services/MessageRetryService.swift
@@ -16,11 +16,11 @@ struct RetryableMessage {
let originalTimestamp: Date?
let content: String
let mentions: [String]?
- let room: String?
+ let channel: String?
let isPrivate: Bool
let recipientPeerID: String?
let recipientNickname: String?
- let roomKey: Data?
+ let channelKey: Data?
let retryCount: Int
let maxRetries: Int = 3
let nextRetryTime: Date
@@ -53,11 +53,11 @@ class MessageRetryService {
func addMessageForRetry(
content: String,
mentions: [String]? = nil,
- room: String? = nil,
+ channel: String? = nil,
isPrivate: Bool = false,
recipientPeerID: String? = nil,
recipientNickname: String? = nil,
- roomKey: Data? = nil,
+ channelKey: Data? = nil,
originalMessageID: String? = nil,
originalTimestamp: Date? = nil
) {
@@ -72,11 +72,11 @@ class MessageRetryService {
originalTimestamp: originalTimestamp,
content: content,
mentions: mentions,
- room: room,
+ channel: channel,
isPrivate: isPrivate,
recipientPeerID: recipientPeerID,
recipientNickname: recipientNickname,
- roomKey: roomKey,
+ channelKey: channelKey,
retryCount: 0,
nextRetryTime: Date().addingTimeInterval(retryInterval)
)
@@ -131,26 +131,26 @@ class MessageRetryService {
originalTimestamp: message.originalTimestamp,
content: message.content,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
- roomKey: message.roomKey,
+ channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
retryQueue.append(updatedMessage)
}
- } else if let room = message.room, let roomKeyData = message.roomKey {
- // For room messages, check if we have peers in the room
+ } else if let channel = message.channel, let channelKeyData = message.channelKey {
+ // For channel messages, check if we have peers in the channel
if !connectedPeers.isEmpty {
// Recreate SymmetricKey from data
- let roomKey = SymmetricKey(data: roomKeyData)
- meshService.sendEncryptedRoomMessage(
+ let channelKey = SymmetricKey(data: channelKeyData)
+ meshService.sendEncryptedChannelMessage(
message.content,
mentions: message.mentions ?? [],
- room: room,
- roomKey: roomKey,
+ channel: channel,
+ channelKey: channelKey,
messageID: message.originalMessageID,
timestamp: message.originalTimestamp
)
@@ -163,11 +163,11 @@ class MessageRetryService {
originalTimestamp: message.originalTimestamp,
content: message.content,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
- roomKey: message.roomKey,
+ channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
@@ -179,7 +179,7 @@ class MessageRetryService {
meshService.sendMessage(
message.content,
mentions: message.mentions ?? [],
- room: message.room,
+ channel: message.channel,
to: nil,
messageID: message.originalMessageID,
timestamp: message.originalTimestamp
@@ -193,11 +193,11 @@ class MessageRetryService {
originalTimestamp: message.originalTimestamp,
content: message.content,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: message.recipientPeerID,
recipientNickname: message.recipientNickname,
- roomKey: message.roomKey,
+ channelKey: message.channelKey,
retryCount: message.retryCount + 1,
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
)
diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift
index 89ec352c..61268a7b 100644
--- a/bitchat/Services/NotificationService.swift
+++ b/bitchat/Services/NotificationService.swift
@@ -61,7 +61,7 @@ class NotificationService {
}
func sendMentionNotification(from sender: String, message: String) {
- let title = "Mentioned by \(sender)"
+ let title = "@🫵 you were mentioned by \(sender)"
let body = message
let identifier = "mention-\(UUID().uuidString)"
@@ -69,7 +69,7 @@ class NotificationService {
}
func sendPrivateMessageNotification(from sender: String, message: String) {
- let title = "Private message from \(sender)"
+ let title = "🔒 private message from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
@@ -83,4 +83,4 @@ class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
-}
\ No newline at end of file
+}
diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift
index 346c76fe..8ae3900b 100644
--- a/bitchat/ViewModels/ChatViewModel.swift
+++ b/bitchat/ViewModels/ChatViewModel.swift
@@ -35,36 +35,38 @@ class ChatViewModel: ObservableObject {
@Published var autocompleteRange: NSRange? = nil
@Published var selectedAutocompleteIndex: Int = 0
- // Room support
- @Published var joinedRooms: Set = [] // Set of room hashtags
- @Published var currentRoom: String? = nil // Currently selected room
- @Published var roomMessages: [String: [BitchatMessage]] = [:] // room -> messages
- @Published var unreadRoomMessages: [String: Int] = [:] // room -> unread count
- @Published var roomMembers: [String: Set] = [:] // room -> set of peer IDs who have sent messages
- @Published var roomPasswords: [String: String] = [:] // room -> password (stored locally only)
- @Published var roomKeys: [String: SymmetricKey] = [:] // room -> derived encryption key
- @Published var passwordProtectedRooms: Set = [] // Set of rooms that require passwords
- @Published var roomCreators: [String: String] = [:] // room -> creator peerID
- @Published var roomKeyCommitments: [String: String] = [:] // room -> SHA256(derivedKey) for verification
+ // Channel support
+ @Published var joinedChannels: Set = [] // Set of channel hashtags
+ @Published var currentChannel: String? = nil // Currently selected channel
+ @Published var channelMessages: [String: [BitchatMessage]] = [:] // channel -> messages
+ @Published var unreadChannelMessages: [String: Int] = [:] // channel -> unread count
+ @Published var channelMembers: [String: Set] = [:] // channel -> set of peer IDs who have sent messages
+ @Published var channelPasswords: [String: String] = [:] // channel -> password (stored locally only)
+ @Published var channelKeys: [String: SymmetricKey] = [:] // channel -> derived encryption key
+ @Published var passwordProtectedChannels: Set = [] // Set of channels that require passwords
+ @Published var channelCreators: [String: String] = [:] // channel -> creator peerID
+ @Published var channelKeyCommitments: [String: String] = [:] // channel -> SHA256(derivedKey) for verification
@Published var showPasswordPrompt: Bool = false
- @Published var passwordPromptRoom: String? = nil
- @Published var savedRooms: Set = [] // Rooms saved for message retention
- @Published var retentionEnabledRooms: Set = [] // Rooms where owner enabled retention for all members
+ @Published var passwordPromptChannel: String? = nil
+ @Published var savedChannels: Set = [] // Channels saved for message retention
+ @Published var retentionEnabledChannels: Set = [] // Channels where owner enabled retention for all members
let meshService = BluetoothMeshService()
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
private let favoritesKey = "bitchat.favorites"
- private let joinedRoomsKey = "bitchat.joinedRooms"
- private let passwordProtectedRoomsKey = "bitchat.passwordProtectedRooms"
- private let roomCreatorsKey = "bitchat.roomCreators"
- // private let roomPasswordsKey = "bitchat.roomPasswords" // Now using Keychain
- private let roomKeyCommitmentsKey = "bitchat.roomKeyCommitments"
- private let retentionEnabledRoomsKey = "bitchat.retentionEnabledRooms"
+ private let joinedChannelsKey = "bitchat.joinedChannels"
+ private let passwordProtectedChannelsKey = "bitchat.passwordProtectedChannels"
+ private let channelCreatorsKey = "bitchat.channelCreators"
+ // private let channelPasswordsKey = "bitchat.channelPasswords" // Now using Keychain
+ private let channelKeyCommitmentsKey = "bitchat.channelKeyCommitments"
+ private let retentionEnabledChannelsKey = "bitchat.retentionEnabledChannels"
+ private let blockedUsersKey = "bitchat.blockedUsers"
private var nicknameSaveTimer: Timer?
@Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs
private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints
+ private var blockedUsers: Set = [] // Stores public key fingerprints of blocked users
// Messages are naturally ephemeral - no persistent storage
@@ -74,10 +76,11 @@ class ChatViewModel: ObservableObject {
init() {
loadNickname()
loadFavorites()
- loadJoinedRooms()
- loadRoomData()
- // Load saved rooms state
- savedRooms = MessageRetentionService.shared.getFavoriteRooms()
+ loadJoinedChannels()
+ loadChannelData()
+ loadBlockedUsers()
+ // Load saved channels state
+ savedChannels = MessageRetentionService.shared.getFavoriteChannels()
meshService.delegate = self
// Log startup info
@@ -98,6 +101,20 @@ class ChatViewModel: ObservableObject {
self?.updateMessageDeliveryStatus(messageID, status: status)
}
+ // Show welcome message after delay if still no peers
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
+ guard let self = self else { return }
+ if self.connectedPeers.isEmpty && self.messages.isEmpty {
+ let welcomeMessage = BitchatMessage(
+ sender: "system",
+ content: "get people around you to download bitchat…and chat with them here!",
+ timestamp: Date(),
+ isRelay: false
+ )
+ self.messages.append(welcomeMessage)
+ }
+ }
+
// When app becomes active, send read receipts for visible messages
#if os(macOS)
NotificationCenter.default.addObserver(
@@ -113,6 +130,14 @@ class ChatViewModel: ObservableObject {
name: UIApplication.didBecomeActiveNotification,
object: nil
)
+
+ // Add screenshot detection for iOS
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(userDidTakeScreenshot),
+ name: UIApplication.userDidTakeScreenshotNotification,
+ object: nil
+ )
#endif
}
@@ -120,7 +145,7 @@ class ChatViewModel: ObservableObject {
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
nickname = savedNickname
} else {
- nickname = "user\(Int.random(in: 1000...9999))"
+ nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname()
}
}
@@ -144,93 +169,104 @@ class ChatViewModel: ObservableObject {
userDefaults.synchronize()
}
- private func loadJoinedRooms() {
- if let savedRoomsList = userDefaults.stringArray(forKey: joinedRoomsKey) {
- joinedRooms = Set(savedRoomsList)
- // Initialize empty data structures for joined rooms
- for room in joinedRooms {
- if roomMessages[room] == nil {
- roomMessages[room] = []
+ private func loadBlockedUsers() {
+ if let savedBlockedUsers = userDefaults.stringArray(forKey: blockedUsersKey) {
+ blockedUsers = Set(savedBlockedUsers)
+ }
+ }
+
+ private func saveBlockedUsers() {
+ userDefaults.set(Array(blockedUsers), forKey: blockedUsersKey)
+ userDefaults.synchronize()
+ }
+
+ private func loadJoinedChannels() {
+ if let savedChannelsList = userDefaults.stringArray(forKey: joinedChannelsKey) {
+ joinedChannels = Set(savedChannelsList)
+ // Initialize empty data structures for joined channels
+ for channel in joinedChannels {
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
}
- if roomMembers[room] == nil {
- roomMembers[room] = Set()
+ if channelMembers[channel] == nil {
+ channelMembers[channel] = Set()
}
- // Load saved messages if this room has retention enabled
- if retentionEnabledRooms.contains(room) {
- let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(room)
+ // Load saved messages if this channel has retention enabled
+ if retentionEnabledChannels.contains(channel) {
+ let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channel)
if !savedMessages.isEmpty {
- roomMessages[room] = savedMessages
+ channelMessages[channel] = savedMessages
}
}
}
}
}
- private func saveJoinedRooms() {
- userDefaults.set(Array(joinedRooms), forKey: joinedRoomsKey)
+ private func saveJoinedChannels() {
+ userDefaults.set(Array(joinedChannels), forKey: joinedChannelsKey)
userDefaults.synchronize()
}
- private func loadRoomData() {
- // Load password protected rooms
- if let savedProtectedRooms = userDefaults.stringArray(forKey: passwordProtectedRoomsKey) {
- passwordProtectedRooms = Set(savedProtectedRooms)
+ private func loadChannelData() {
+ // Load password protected channels
+ if let savedProtectedChannels = userDefaults.stringArray(forKey: passwordProtectedChannelsKey) {
+ passwordProtectedChannels = Set(savedProtectedChannels)
}
- // Load room creators
- if let savedCreators = userDefaults.dictionary(forKey: roomCreatorsKey) as? [String: String] {
- roomCreators = savedCreators
+ // Load channel creators
+ if let savedCreators = userDefaults.dictionary(forKey: channelCreatorsKey) as? [String: String] {
+ channelCreators = savedCreators
}
- // Load room key commitments
- if let savedCommitments = userDefaults.dictionary(forKey: roomKeyCommitmentsKey) as? [String: String] {
- roomKeyCommitments = savedCommitments
+ // Load channel key commitments
+ if let savedCommitments = userDefaults.dictionary(forKey: channelKeyCommitmentsKey) as? [String: String] {
+ channelKeyCommitments = savedCommitments
}
- // Load retention-enabled rooms
- if let savedRetentionRooms = userDefaults.stringArray(forKey: retentionEnabledRoomsKey) {
- retentionEnabledRooms = Set(savedRetentionRooms)
+ // Load retention-enabled channels
+ if let savedRetentionChannels = userDefaults.stringArray(forKey: retentionEnabledChannelsKey) {
+ retentionEnabledChannels = Set(savedRetentionChannels)
}
- // Load room passwords from Keychain
- let savedPasswords = KeychainManager.shared.getAllRoomPasswords()
- roomPasswords = savedPasswords
+ // Load channel passwords from Keychain
+ let savedPasswords = KeychainManager.shared.getAllChannelPasswords()
+ channelPasswords = savedPasswords
// Derive keys for all saved passwords
- for (room, password) in savedPasswords {
- roomKeys[room] = deriveRoomKey(from: password, roomName: room)
+ for (channel, password) in savedPasswords {
+ channelKeys[channel] = deriveChannelKey(from: password, channelName: channel)
}
}
- private func saveRoomData() {
- userDefaults.set(Array(passwordProtectedRooms), forKey: passwordProtectedRoomsKey)
- userDefaults.set(roomCreators, forKey: roomCreatorsKey)
+ private func saveChannelData() {
+ userDefaults.set(Array(passwordProtectedChannels), forKey: passwordProtectedChannelsKey)
+ userDefaults.set(channelCreators, forKey: channelCreatorsKey)
// Save passwords to Keychain instead of UserDefaults
- for (room, password) in roomPasswords {
- _ = KeychainManager.shared.saveRoomPassword(password, for: room)
+ for (channel, password) in channelPasswords {
+ _ = KeychainManager.shared.saveChannelPassword(password, for: channel)
}
- userDefaults.set(roomKeyCommitments, forKey: roomKeyCommitmentsKey)
- userDefaults.set(Array(retentionEnabledRooms), forKey: retentionEnabledRoomsKey)
+ userDefaults.set(channelKeyCommitments, forKey: channelKeyCommitmentsKey)
+ userDefaults.set(Array(retentionEnabledChannels), forKey: retentionEnabledChannelsKey)
userDefaults.synchronize()
}
- func joinRoom(_ room: String, password: String? = nil) -> Bool {
- // Ensure room starts with #
- let roomTag = room.hasPrefix("#") ? room : "#\(room)"
+ func joinChannel(_ channel: String, password: String? = nil) -> Bool {
+ // Ensure channel starts with #
+ let channelTag = channel.hasPrefix("#") ? channel : "#\(channel)"
- // Check if room is already joined and we can access it
- if joinedRooms.contains(roomTag) {
+ // Check if channel is already joined and we can access it
+ if joinedChannels.contains(channelTag) {
// Already joined, check if we need password verification
- if passwordProtectedRooms.contains(roomTag) && roomKeys[roomTag] == nil {
+ if passwordProtectedChannels.contains(channelTag) && channelKeys[channelTag] == nil {
if let password = password {
- // User provided password for already-joined room - verify it
+ // User provided password for already-joined channel - verify it
// Derive key and try to verify
- let key = deriveRoomKey(from: password, roomName: roomTag)
+ let key = deriveChannelKey(from: password, channelName: channelTag)
// First, check if we have a key commitment to verify against
- if let expectedCommitment = roomKeyCommitments[roomTag] {
+ if let expectedCommitment = channelKeyCommitments[channelTag] {
let actualCommitment = computeKeyCommitment(for: key)
if actualCommitment != expectedCommitment {
return false
@@ -238,11 +274,11 @@ class ChatViewModel: ObservableObject {
}
// Check if we have messages to verify against
- if let roomMsgs = roomMessages[roomTag], !roomMsgs.isEmpty {
- let encryptedMessages = roomMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
+ if let channelMsgs = channelMessages[channelTag], !channelMsgs.isEmpty {
+ let encryptedMessages = channelMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
if let encryptedMsg = encryptedMessages.first,
let encryptedData = encryptedMsg.encryptedContent {
- let testDecrypted = decryptRoomMessage(encryptedData, room: roomTag, testKey: key)
+ let testDecrypted = decryptChannelMessage(encryptedData, channel: channelTag, testKey: key)
if testDecrypted == nil {
return false
}
@@ -250,36 +286,36 @@ class ChatViewModel: ObservableObject {
}
// Store the verified key
- roomKeys[roomTag] = key
- roomPasswords[roomTag] = password
+ channelKeys[channelTag] = key
+ channelPasswords[channelTag] = password
- // Now switch to the room
- switchToRoom(roomTag)
+ // Now switch to the channel
+ switchToChannel(channelTag)
return true
} else {
// Need password to access
- passwordPromptRoom = roomTag
+ passwordPromptChannel = channelTag
showPasswordPrompt = true
return false
}
}
- // Switch to the room (no password needed)
- switchToRoom(roomTag)
+ // Switch to the channel (no password needed)
+ switchToChannel(channelTag)
return true
}
- // If room is password protected and we don't have the key yet
- if passwordProtectedRooms.contains(roomTag) && roomKeys[roomTag] == nil {
- // Allow room creator to bypass password check
- if roomCreators[roomTag] == meshService.myPeerID {
- // Room creator should already have the key set when they created the password
+ // If channel is password protected and we don't have the key yet
+ if passwordProtectedChannels.contains(channelTag) && channelKeys[channelTag] == nil {
+ // Allow channel creator to bypass password check
+ if channelCreators[channelTag] == meshService.myPeerID {
+ // Channel creator should already have the key set when they created the password
// This is a failsafe - just proceed without password
} else if let password = password {
// Derive key from password
- let key = deriveRoomKey(from: password, roomName: roomTag)
+ let key = deriveChannelKey(from: password, channelName: channelTag)
// First, check if we have a key commitment to verify against
- if let expectedCommitment = roomKeyCommitments[roomTag] {
+ if let expectedCommitment = channelKeyCommitments[channelTag] {
let actualCommitment = computeKeyCommitment(for: key)
if actualCommitment != expectedCommitment {
return false
@@ -290,14 +326,14 @@ class ChatViewModel: ObservableObject {
var passwordVerified = false
var shouldProceed = true
- if let roomMsgs = roomMessages[roomTag], !roomMsgs.isEmpty {
+ if let channelMsgs = channelMessages[channelTag], !channelMsgs.isEmpty {
// Look for encrypted messages to verify against
- let encryptedMessages = roomMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
+ let encryptedMessages = channelMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
if let encryptedMsg = encryptedMessages.first,
let encryptedData = encryptedMsg.encryptedContent {
// Test decryption with the derived key
- let testDecrypted = decryptRoomMessage(encryptedData, room: roomTag, testKey: key)
+ let testDecrypted = decryptChannelMessage(encryptedData, channel: channelTag, testKey: key)
if testDecrypted == nil {
// Password is wrong, can't decrypt
shouldProceed = false
@@ -310,19 +346,19 @@ class ChatViewModel: ObservableObject {
// Add warning message
let warningMsg = BitchatMessage(
sender: "system",
- content: "joined room \(roomTag). password will be verified when encrypted messages arrive.",
+ content: "joined channel \(channelTag). password will be verified when encrypted messages arrive.",
timestamp: Date(),
isRelay: false
)
messages.append(warningMsg)
}
} else {
- // Empty room - accept tentatively
+ // Empty channel - accept tentatively
// Add info message
let infoMsg = BitchatMessage(
sender: "system",
- content: "joined empty room \(roomTag). waiting for encrypted messages to verify password.",
+ content: "joined empty channel \(channelTag). waiting for encrypted messages to verify password.",
timestamp: Date(),
isRelay: false
)
@@ -335,141 +371,141 @@ class ChatViewModel: ObservableObject {
}
// Store the key (tentatively if not verified)
- roomKeys[roomTag] = key
- roomPasswords[roomTag] = password
+ channelKeys[channelTag] = key
+ channelPasswords[channelTag] = password
// Save password to Keychain
- _ = KeychainManager.shared.saveRoomPassword(password, for: roomTag)
+ _ = KeychainManager.shared.saveChannelPassword(password, for: channelTag)
if passwordVerified {
} else {
}
} else {
- // Show password prompt and return early - don't join the room yet
- passwordPromptRoom = roomTag
+ // Show password prompt and return early - don't join the channel yet
+ passwordPromptChannel = channelTag
showPasswordPrompt = true
return false
}
}
- // At this point, room is either not password protected or we don't know yet
+ // At this point, channel is either not password protected or we don't know yet
- joinedRooms.insert(roomTag)
- saveJoinedRooms()
+ joinedChannels.insert(channelTag)
+ saveJoinedChannels()
- // Only claim creator role if this is a brand new room (no one has announced it as protected)
+ // Only claim creator role if this is a brand new channel (no one has announced it as protected)
// If it's password protected, someone else already created it
- if roomCreators[roomTag] == nil && !passwordProtectedRooms.contains(roomTag) {
- roomCreators[roomTag] = meshService.myPeerID
- saveRoomData()
+ if channelCreators[channelTag] == nil && !passwordProtectedChannels.contains(channelTag) {
+ channelCreators[channelTag] = meshService.myPeerID
+ saveChannelData()
}
// Add ourselves as a member
- if roomMembers[roomTag] == nil {
- roomMembers[roomTag] = Set()
+ if channelMembers[channelTag] == nil {
+ channelMembers[channelTag] = Set()
}
- roomMembers[roomTag]?.insert(meshService.myPeerID)
+ channelMembers[channelTag]?.insert(meshService.myPeerID)
- // Switch to the room
- currentRoom = roomTag
+ // Switch to the channel
+ currentChannel = channelTag
selectedPrivateChatPeer = nil // Exit private chat if in one
- // Clear unread count for this room
- unreadRoomMessages[roomTag] = 0
+ // Clear unread count for this channel
+ unreadChannelMessages[channelTag] = 0
- // Initialize room messages if needed
- if roomMessages[roomTag] == nil {
- roomMessages[roomTag] = []
+ // Initialize channel messages if needed
+ if channelMessages[channelTag] == nil {
+ channelMessages[channelTag] = []
}
- // Load saved messages if this is a favorite room
- if MessageRetentionService.shared.getFavoriteRooms().contains(roomTag) {
- let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(roomTag)
+ // Load saved messages if this is a favorite channel
+ if MessageRetentionService.shared.getFavoriteChannels().contains(channelTag) {
+ let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channelTag)
if !savedMessages.isEmpty {
// Merge saved messages with current messages, avoiding duplicates
- var existingMessageIDs = Set(roomMessages[roomTag]?.map { $0.id } ?? [])
+ var existingMessageIDs = Set(channelMessages[channelTag]?.map { $0.id } ?? [])
for savedMessage in savedMessages {
if !existingMessageIDs.contains(savedMessage.id) {
- roomMessages[roomTag]?.append(savedMessage)
+ channelMessages[channelTag]?.append(savedMessage)
existingMessageIDs.insert(savedMessage.id)
}
}
// Sort by timestamp
- roomMessages[roomTag]?.sort { $0.timestamp < $1.timestamp }
+ channelMessages[channelTag]?.sort { $0.timestamp < $1.timestamp }
}
}
// Hide password prompt if it was showing
showPasswordPrompt = false
- passwordPromptRoom = nil
+ passwordPromptChannel = nil
return true
}
- func leaveRoom(_ room: String) {
- joinedRooms.remove(room)
- saveJoinedRooms()
+ func leaveChannel(_ channel: String) {
+ joinedChannels.remove(channel)
+ saveJoinedChannels()
// Send leave notification to other peers
- meshService.sendRoomLeaveNotification(room)
+ meshService.sendChannelLeaveNotification(channel)
- // If we're currently in this room, exit to main chat
- if currentRoom == room {
- currentRoom = nil
+ // If we're currently in this channel, exit to main chat
+ if currentChannel == channel {
+ currentChannel = nil
}
- // Clean up room data
- unreadRoomMessages.removeValue(forKey: room)
- roomMessages.removeValue(forKey: room)
- roomMembers.removeValue(forKey: room)
- roomKeys.removeValue(forKey: room)
- roomPasswords.removeValue(forKey: room)
+ // Clean up channel data
+ unreadChannelMessages.removeValue(forKey: channel)
+ channelMessages.removeValue(forKey: channel)
+ channelMembers.removeValue(forKey: channel)
+ channelKeys.removeValue(forKey: channel)
+ channelPasswords.removeValue(forKey: channel)
// Delete password from Keychain
- _ = KeychainManager.shared.deleteRoomPassword(for: room)
+ _ = KeychainManager.shared.deleteChannelPassword(for: channel)
}
// Password management
- func setRoomPassword(_ password: String, for room: String) {
- guard joinedRooms.contains(room) else { return }
+ func setChannelPassword(_ password: String, for channel: String) {
+ guard joinedChannels.contains(channel) else { return }
- // Check if room already has a creator
- if let existingCreator = roomCreators[room], existingCreator != meshService.myPeerID {
+ // Check if channel already has a creator
+ if let existingCreator = channelCreators[channel], existingCreator != meshService.myPeerID {
return
}
- // If room is already password protected by someone else, we can't claim it
- if passwordProtectedRooms.contains(room) && roomCreators[room] != meshService.myPeerID {
+ // If channel is already password protected by someone else, we can't claim it
+ if passwordProtectedChannels.contains(channel) && channelCreators[channel] != meshService.myPeerID {
return
}
- // Claim creator role if not set and room is not already protected
- if roomCreators[room] == nil && !passwordProtectedRooms.contains(room) {
- roomCreators[room] = meshService.myPeerID
- saveRoomData()
+ // Claim creator role if not set and channel is not already protected
+ if channelCreators[channel] == nil && !passwordProtectedChannels.contains(channel) {
+ channelCreators[channel] = meshService.myPeerID
+ saveChannelData()
}
// Derive encryption key from password
- let key = deriveRoomKey(from: password, roomName: room)
- roomKeys[room] = key
- roomPasswords[room] = password
- passwordProtectedRooms.insert(room)
+ let key = deriveChannelKey(from: password, channelName: channel)
+ channelKeys[channel] = key
+ channelPasswords[channel] = password
+ passwordProtectedChannels.insert(channel)
// Save password to Keychain
- _ = KeychainManager.shared.saveRoomPassword(password, for: room)
+ _ = KeychainManager.shared.saveChannelPassword(password, for: channel)
// Compute and store key commitment for verification
let commitment = computeKeyCommitment(for: key)
- roomKeyCommitments[room] = commitment
+ channelKeyCommitments[channel] = commitment
- // Save room data
- saveRoomData()
+ // Save channel data
+ saveChannelData()
- // Announce that this room is now password protected with commitment
- meshService.announcePasswordProtectedRoom(room, creatorID: meshService.myPeerID, keyCommitment: commitment)
+ // Announce that this channel is now password protected with commitment
+ meshService.announcePasswordProtectedChannel(channel, creatorID: meshService.myPeerID, keyCommitment: commitment)
// Send an encrypted initialization message with metadata
let timestamp = ISO8601DateFormatter().string(from: Date())
let metadata = [
- "type": "room_init",
- "room": room,
+ "type": "channel_init",
+ "channel": channel,
"creator": nickname,
"creatorID": meshService.myPeerID,
"timestamp": timestamp,
@@ -478,38 +514,38 @@ class ChatViewModel: ObservableObject {
let jsonData = try? JSONSerialization.data(withJSONObject: metadata)
let metadataStr = jsonData?.base64EncodedString() ?? ""
- let initMessage = "🔐 Room \(room) initialized | Protected room created by \(nickname) | Metadata: \(metadataStr)"
- meshService.sendEncryptedRoomMessage(initMessage, mentions: [], room: room, roomKey: key)
+ let initMessage = "🔐 Channel \(channel) initialized | Protected channel created by \(nickname) | Metadata: \(metadataStr)"
+ meshService.sendEncryptedChannelMessage(initMessage, mentions: [], channel: channel, channelKey: key)
}
- func removeRoomPassword(for room: String) {
- // Only room creator can remove password
- guard roomCreators[room] == meshService.myPeerID else {
+ func removeChannelPassword(for channel: String) {
+ // Only channel creator can remove password
+ guard channelCreators[channel] == meshService.myPeerID else {
return
}
- roomKeys.removeValue(forKey: room)
- roomPasswords.removeValue(forKey: room)
- roomKeyCommitments.removeValue(forKey: room)
- passwordProtectedRooms.remove(room)
+ channelKeys.removeValue(forKey: channel)
+ channelPasswords.removeValue(forKey: channel)
+ channelKeyCommitments.removeValue(forKey: channel)
+ passwordProtectedChannels.remove(channel)
// Delete password from Keychain
- _ = KeychainManager.shared.deleteRoomPassword(for: room)
+ _ = KeychainManager.shared.deleteChannelPassword(for: channel)
- // Save room data
- saveRoomData()
+ // Save channel data
+ saveChannelData()
- // Announce that this room is no longer password protected
- meshService.announcePasswordProtectedRoom(room, isProtected: false, creatorID: meshService.myPeerID)
+ // Announce that this channel is no longer password protected
+ meshService.announcePasswordProtectedChannel(channel, isProtected: false, creatorID: meshService.myPeerID)
}
- // Transfer room ownership to another user
- func transferRoomOwnership(to nickname: String) {
- guard let currentRoom = currentRoom else {
+ // Transfer channel ownership to another user
+ func transferChannelOwnership(to nickname: String) {
+ guard let currentChannel = currentChannel else {
let msg = BitchatMessage(
sender: "system",
- content: "you must be in a room to transfer ownership.",
+ content: "you must be in a channel to transfer ownership.",
timestamp: Date(),
isRelay: false
)
@@ -518,10 +554,10 @@ class ChatViewModel: ObservableObject {
}
// Check if current user is the owner
- guard roomCreators[currentRoom] == meshService.myPeerID else {
+ guard channelCreators[currentChannel] == meshService.myPeerID else {
let msg = BitchatMessage(
sender: "system",
- content: "only the room owner can transfer ownership.",
+ content: "only the channel owner can transfer ownership.",
timestamp: Date(),
isRelay: false
)
@@ -545,41 +581,41 @@ class ChatViewModel: ObservableObject {
}
// Update ownership
- roomCreators[currentRoom] = targetPeerID
- saveRoomData()
+ channelCreators[currentChannel] = targetPeerID
+ saveChannelData()
// Announce the ownership transfer
- if passwordProtectedRooms.contains(currentRoom) {
- let commitment = roomKeyCommitments[currentRoom]
- meshService.announcePasswordProtectedRoom(currentRoom, creatorID: targetPeerID, keyCommitment: commitment)
+ if passwordProtectedChannels.contains(currentChannel) {
+ let commitment = channelKeyCommitments[currentChannel]
+ meshService.announcePasswordProtectedChannel(currentChannel, creatorID: targetPeerID, keyCommitment: commitment)
}
// Send notification message
let transferMsg = BitchatMessage(
sender: "system",
- content: "room ownership transferred from \(self.nickname) to \(targetNick).",
+ content: "channel ownership transferred from \(self.nickname) to \(targetNick).",
timestamp: Date(),
isRelay: false,
- room: currentRoom
+ channel: currentChannel
)
messages.append(transferMsg)
- // Send encrypted notification if room is protected
- if let roomKey = roomKeys[currentRoom] {
- let notifyMsg = "🔑 Room ownership transferred to \(targetNick) by \(self.nickname)"
- meshService.sendEncryptedRoomMessage(notifyMsg, mentions: [targetNick], room: currentRoom, roomKey: roomKey)
+ // Send encrypted notification if channel is protected
+ if let channelKey = channelKeys[currentChannel] {
+ let notifyMsg = "🔑 Channel ownership transferred to \(targetNick) by \(self.nickname)"
+ meshService.sendEncryptedChannelMessage(notifyMsg, mentions: [targetNick], channel: currentChannel, channelKey: channelKey)
} else {
meshService.sendMessage(transferMsg.content, mentions: [targetNick])
}
}
- // Change password for current room
- func changeRoomPassword(to newPassword: String) {
- guard let currentRoom = currentRoom else {
+ // Change password for current channel
+ func changeChannelPassword(to newPassword: String) {
+ guard let currentChannel = currentChannel else {
let msg = BitchatMessage(
sender: "system",
- content: "you must be in a room to change its password.",
+ content: "you must be in a channel to change its password.",
timestamp: Date(),
isRelay: false
)
@@ -588,10 +624,10 @@ class ChatViewModel: ObservableObject {
}
// Check if current user is the owner
- guard roomCreators[currentRoom] == meshService.myPeerID else {
+ guard channelCreators[currentChannel] == meshService.myPeerID else {
let msg = BitchatMessage(
sender: "system",
- content: "only the room owner can change the password.",
+ content: "only the channel owner can change the password.",
timestamp: Date(),
isRelay: false
)
@@ -599,11 +635,11 @@ class ChatViewModel: ObservableObject {
return
}
- // Check if room is currently password protected
- guard passwordProtectedRooms.contains(currentRoom) else {
+ // Check if channel is currently password protected
+ guard passwordProtectedChannels.contains(currentChannel) else {
let msg = BitchatMessage(
sender: "system",
- content: "room is not password protected. use the lock button to set a password.",
+ content: "channel is not password protected. use the lock button to set a password.",
timestamp: Date(),
isRelay: false
)
@@ -612,33 +648,33 @@ class ChatViewModel: ObservableObject {
}
// Store old key for re-encryption
- let oldKey = roomKeys[currentRoom]
+ let oldKey = channelKeys[currentChannel]
// Derive new encryption key from new password
- let newKey = deriveRoomKey(from: newPassword, roomName: currentRoom)
- roomKeys[currentRoom] = newKey
- roomPasswords[currentRoom] = newPassword
+ let newKey = deriveChannelKey(from: newPassword, channelName: currentChannel)
+ channelKeys[currentChannel] = newKey
+ channelPasswords[currentChannel] = newPassword
// Update password in Keychain
- _ = KeychainManager.shared.saveRoomPassword(newPassword, for: currentRoom)
+ _ = KeychainManager.shared.saveChannelPassword(newPassword, for: currentChannel)
// Compute new key commitment
let newCommitment = computeKeyCommitment(for: newKey)
- roomKeyCommitments[currentRoom] = newCommitment
+ channelKeyCommitments[currentChannel] = newCommitment
- // Save room data
- saveRoomData()
+ // Save channel data
+ saveChannelData()
// Send password change notification with old key
if let oldKey = oldKey {
- let changeNotice = "🔐 Password changed by room owner. Please update your password."
- meshService.sendEncryptedRoomMessage(changeNotice, mentions: [], room: currentRoom, roomKey: oldKey)
+ let changeNotice = "🔐 Password changed by channel owner. Please update your password."
+ meshService.sendEncryptedChannelMessage(changeNotice, mentions: [], channel: currentChannel, channelKey: oldKey)
}
// Send new initialization message with new key
let timestamp = ISO8601DateFormatter().string(from: Date())
let metadata = [
"type": "password_change",
- "room": currentRoom,
+ "channel": currentChannel,
"changer": nickname,
"changerID": meshService.myPeerID,
"timestamp": timestamp,
@@ -647,11 +683,11 @@ class ChatViewModel: ObservableObject {
let jsonData = try? JSONSerialization.data(withJSONObject: metadata)
let metadataStr = jsonData?.base64EncodedString() ?? ""
- let initMessage = "🔑 Password changed | Room \(currentRoom) password updated by \(nickname) | Metadata: \(metadataStr)"
- meshService.sendEncryptedRoomMessage(initMessage, mentions: [], room: currentRoom, roomKey: newKey)
+ let initMessage = "🔑 Password changed | Channel \(currentChannel) password updated by \(nickname) | Metadata: \(metadataStr)"
+ meshService.sendEncryptedChannelMessage(initMessage, mentions: [], channel: currentChannel, channelKey: newKey)
// Announce the new commitment
- meshService.announcePasswordProtectedRoom(currentRoom, creatorID: meshService.myPeerID, keyCommitment: newCommitment)
+ meshService.announcePasswordProtectedChannel(currentChannel, creatorID: meshService.myPeerID, keyCommitment: newCommitment)
// Add local success message
let successMsg = BitchatMessage(
@@ -671,9 +707,9 @@ class ChatViewModel: ObservableObject {
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
- private func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey {
+ private func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
// Use PBKDF2 to derive a key from the password
- let salt = roomName.data(using: .utf8)! // Use room name as salt for consistency
+ let salt = channelName.data(using: .utf8)! // Use channel name as salt for consistency
let keyData = pbkdf2(password: password, salt: salt, iterations: 100000, keyLength: 32)
return SymmetricKey(data: keyData)
}
@@ -700,42 +736,42 @@ class ChatViewModel: ObservableObject {
return derivedKey
}
- func switchToRoom(_ room: String?) {
- // Check if room needs password
- if let room = room, passwordProtectedRooms.contains(room) && roomKeys[room] == nil {
+ func switchToChannel(_ channel: String?) {
+ // Check if channel needs password
+ if let channel = channel, passwordProtectedChannels.contains(channel) && channelKeys[channel] == nil {
// Need password, show prompt instead
- passwordPromptRoom = room
+ passwordPromptChannel = channel
showPasswordPrompt = true
return
}
- currentRoom = room
+ currentChannel = channel
selectedPrivateChatPeer = nil // Exit private chat
- // Clear unread count for this room
- if let room = room {
- unreadRoomMessages[room] = 0
+ // Clear unread count for this channel
+ if let channel = channel {
+ unreadChannelMessages[channel] = 0
}
}
- func getRoomMessages(_ room: String) -> [BitchatMessage] {
- return roomMessages[room] ?? []
+ func getChannelMessages(_ channel: String) -> [BitchatMessage] {
+ return channelMessages[channel] ?? []
}
- func parseRooms(from content: String) -> Set {
+ func parseChannels(from content: String) -> Set {
let pattern = "#([a-zA-Z0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
- var rooms = Set()
+ var channels = Set()
for match in matches {
if let range = Range(match.range(at: 0), in: content) {
- let room = String(content[range])
- rooms.insert(room)
+ let channel = String(content[range])
+ channels.insert(channel)
}
}
- return rooms
+ return channels
}
func toggleFavorite(peerID: String) {
@@ -780,6 +816,25 @@ class ChatViewModel: ObservableObject {
}
}
+ private func isPeerBlocked(_ peerID: String) -> Bool {
+ // Check if we have the public key fingerprint for this peer
+ if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
+ return blockedUsers.contains(fingerprint)
+ }
+
+ // Try to get public key from mesh service
+ if let publicKeyData = meshService.getPeerPublicKey(peerID) {
+ let fingerprint = SHA256.hash(data: publicKeyData)
+ .compactMap { String(format: "%02x", $0) }
+ .joined()
+ .prefix(16)
+ .lowercased()
+ return blockedUsers.contains(String(fingerprint))
+ }
+
+ return false
+ }
+
func sendMessage(_ content: String) {
guard !content.isEmpty else { return }
@@ -793,19 +848,19 @@ class ChatViewModel: ObservableObject {
// Send as private message
sendPrivateMessage(content, to: selectedPeer)
} else {
- // Parse mentions and rooms from the content
+ // Parse mentions and channels from the content
let mentions = parseMentions(from: content)
- let rooms = parseRooms(from: content)
+ let channels = parseChannels(from: content)
- // Auto-join any rooms mentioned in the message
- for room in rooms {
- if !joinedRooms.contains(room) {
- let _ = joinRoom(room)
+ // Auto-join any channels mentioned in the message
+ for channel in channels {
+ if !joinedChannels.contains(channel) {
+ let _ = joinChannel(channel)
}
}
- // Determine which room this message belongs to
- let messageRoom = currentRoom // Use current room if we're in one
+ // Determine which channel this message belongs to
+ let messageChannel = currentChannel // Use current channel if we're in one
// Add message to local display
let message = BitchatMessage(
@@ -818,45 +873,45 @@ class ChatViewModel: ObservableObject {
recipientNickname: nil,
senderPeerID: meshService.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
- room: messageRoom
+ channel: messageChannel
)
- if let room = messageRoom {
- // Add to room messages
- if roomMessages[room] == nil {
- roomMessages[room] = []
+ if let channel = messageChannel {
+ // Add to channel messages
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
}
- roomMessages[room]?.append(message)
+ channelMessages[channel]?.append(message)
- // Save message if room has retention enabled
- if retentionEnabledRooms.contains(room) {
- MessageRetentionService.shared.saveMessage(message, forRoom: room)
+ // Save message if channel has retention enabled
+ if retentionEnabledChannels.contains(channel) {
+ MessageRetentionService.shared.saveMessage(message, forChannel: channel)
}
- // Track ourselves as a room member
- if roomMembers[room] == nil {
- roomMembers[room] = Set()
+ // Track ourselves as a channel member
+ if channelMembers[channel] == nil {
+ channelMembers[channel] = Set()
}
- roomMembers[room]?.insert(meshService.myPeerID)
+ channelMembers[channel]?.insert(meshService.myPeerID)
} else {
// Add to main messages
messages.append(message)
}
- // Only auto-join rooms if we're sending TO that room
- if let messageRoom = messageRoom {
- if !joinedRooms.contains(messageRoom) {
- let _ = joinRoom(messageRoom)
+ // Only auto-join channels if we're sending TO that channel
+ if let messageChannel = messageChannel {
+ if !joinedChannels.contains(messageChannel) {
+ let _ = joinChannel(messageChannel)
}
}
- // Check if room is password protected and encrypt if needed
- if let room = messageRoom, roomKeys[room] != nil {
- // Send encrypted room message
- meshService.sendEncryptedRoomMessage(content, mentions: mentions, room: room, roomKey: roomKeys[room]!)
+ // Check if channel is password protected and encrypt if needed
+ if let channel = messageChannel, channelKeys[channel] != nil {
+ // Send encrypted channel message
+ meshService.sendEncryptedChannelMessage(content, mentions: mentions, channel: channel, channelKey: channelKeys[channel]!)
} else {
- // Send via mesh with mentions and room (unencrypted)
- meshService.sendMessage(content, mentions: mentions, room: messageRoom)
+ // Send via mesh with mentions and channel (unencrypted)
+ meshService.sendMessage(content, mentions: mentions, channel: messageChannel)
}
}
}
@@ -865,6 +920,18 @@ class ChatViewModel: ObservableObject {
guard !content.isEmpty else { return }
guard let recipientNickname = meshService.getPeerNicknames()[peerID] else { return }
+ // Check if the recipient is blocked
+ if isPeerBlocked(peerID) {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot send message to \(recipientNickname): user is blocked.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ return
+ }
+
// IMPORTANT: When sending a message, it means we're viewing this chat
// Send read receipts for any delivered messages from this peer
markPrivateMessagesAsRead(from: peerID)
@@ -901,6 +968,19 @@ class ChatViewModel: ObservableObject {
func startPrivateChat(with peerID: String) {
let peerNickname = meshService.getPeerNicknames()[peerID] ?? "unknown"
+
+ // Check if the peer is blocked
+ if isPeerBlocked(peerID) {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot start chat with \(peerNickname): user is blocked.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ return
+ }
+
selectedPrivateChatPeer = peerID
unreadPrivateMessages.remove(peerID)
@@ -986,6 +1066,72 @@ class ChatViewModel: ObservableObject {
}
}
+ @objc private func userDidTakeScreenshot() {
+ // Send screenshot notification based on current context
+ let screenshotMessage = "* \(nickname) took a screenshot *"
+
+ if let peerID = selectedPrivateChatPeer {
+ // In private chat - send to the other person
+ if let peerNickname = meshService.getPeerNicknames()[peerID] {
+ // Send the message directly without going through sendPrivateMessage to avoid local echo
+ meshService.sendPrivateMessage(screenshotMessage, to: peerID, recipientNickname: peerNickname)
+ }
+
+ // Show local notification immediately as system message
+ let localNotification = BitchatMessage(
+ sender: "system",
+ content: "you took a screenshot",
+ timestamp: Date(),
+ isRelay: false,
+ originalSender: nil,
+ isPrivate: true,
+ recipientNickname: meshService.getPeerNicknames()[peerID],
+ senderPeerID: meshService.myPeerID
+ )
+ if privateChats[peerID] == nil {
+ privateChats[peerID] = []
+ }
+ privateChats[peerID]?.append(localNotification)
+
+ } else if let channel = currentChannel {
+ // In a channel - send to channel
+ // Check if channel is password protected and encrypt if needed
+ if let channelKey = channelKeys[channel] {
+ meshService.sendEncryptedChannelMessage(screenshotMessage, mentions: [], channel: channel, channelKey: channelKey)
+ } else {
+ meshService.sendMessage(screenshotMessage, mentions: [], channel: channel)
+ }
+
+ // Show local notification immediately as system message
+ let localNotification = BitchatMessage(
+ sender: "system",
+ content: "you took a screenshot",
+ timestamp: Date(),
+ isRelay: false,
+ originalSender: nil,
+ isPrivate: false,
+ channel: channel
+ )
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
+ }
+ channelMessages[channel]?.append(localNotification)
+
+ } else {
+ // In public chat - send to everyone
+ meshService.sendMessage(screenshotMessage, mentions: [], channel: nil)
+
+ // Show local notification immediately as system message
+ let localNotification = BitchatMessage(
+ sender: "system",
+ content: "you took a screenshot",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(localNotification)
+ }
+ }
+
func markPrivateMessagesAsRead(from peerID: String) {
// Get the nickname for this peer
let peerNickname = meshService.getPeerNicknames()[peerID] ?? ""
@@ -1077,37 +1223,37 @@ class ChatViewModel: ObservableObject {
privateChats.removeAll()
unreadPrivateMessages.removeAll()
- // Clear all room data
- joinedRooms.removeAll()
- currentRoom = nil
- roomMessages.removeAll()
- unreadRoomMessages.removeAll()
- roomMembers.removeAll()
- roomPasswords.removeAll()
- roomKeys.removeAll()
- passwordProtectedRooms.removeAll()
- roomCreators.removeAll()
- roomKeyCommitments.removeAll()
+ // Clear all channel data
+ joinedChannels.removeAll()
+ currentChannel = nil
+ channelMessages.removeAll()
+ unreadChannelMessages.removeAll()
+ channelMembers.removeAll()
+ channelPasswords.removeAll()
+ channelKeys.removeAll()
+ passwordProtectedChannels.removeAll()
+ channelCreators.removeAll()
+ channelKeyCommitments.removeAll()
showPasswordPrompt = false
- passwordPromptRoom = nil
+ passwordPromptChannel = nil
// Clear all keychain passwords
_ = KeychainManager.shared.deleteAllPasswords()
// Clear all retained messages
MessageRetentionService.shared.deleteAllStoredMessages()
- savedRooms.removeAll()
- retentionEnabledRooms.removeAll()
+ savedChannels.removeAll()
+ retentionEnabledChannels.removeAll()
// Clear message retry queue
MessageRetryService.shared.clearRetryQueue()
- // Clear persisted room data from UserDefaults
- userDefaults.removeObject(forKey: joinedRoomsKey)
- userDefaults.removeObject(forKey: passwordProtectedRoomsKey)
- userDefaults.removeObject(forKey: roomCreatorsKey)
- userDefaults.removeObject(forKey: roomKeyCommitmentsKey)
- userDefaults.removeObject(forKey: retentionEnabledRoomsKey)
+ // Clear persisted channel data from UserDefaults
+ userDefaults.removeObject(forKey: joinedChannelsKey)
+ userDefaults.removeObject(forKey: passwordProtectedChannelsKey)
+ userDefaults.removeObject(forKey: channelCreatorsKey)
+ userDefaults.removeObject(forKey: channelKeyCommitmentsKey)
+ userDefaults.removeObject(forKey: retentionEnabledChannelsKey)
// Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))"
@@ -1318,6 +1464,124 @@ class ChatViewModel: ObservableObject {
return processedContent
}
+ func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
+ var result = AttributedString()
+
+ let isDark = colorScheme == .dark
+ let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
+ let secondaryColor = primaryColor.opacity(0.7)
+
+ // Timestamp
+ let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
+ var timestampStyle = AttributeContainer()
+ timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
+ timestampStyle.font = .system(size: 12, design: .monospaced)
+ result.append(timestamp.mergingAttributes(timestampStyle))
+
+ if message.sender != "system" {
+ // Sender
+ let sender = AttributedString("<@\(message.sender)> ")
+ var senderStyle = AttributeContainer()
+
+ // Get sender color
+ let senderColor: Color
+ if message.sender == nickname {
+ senderColor = primaryColor
+ } else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
+ let rssi = meshService.getPeerRSSI()[peerID] {
+ senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
+ } else {
+ senderColor = primaryColor.opacity(0.9)
+ }
+
+ senderStyle.foregroundColor = senderColor
+ senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced)
+ result.append(sender.mergingAttributes(senderStyle))
+
+ // Process content with hashtags and mentions
+ let content = message.content
+ let hashtagPattern = "#([a-zA-Z0-9_]+)"
+ let mentionPattern = "@([a-zA-Z0-9_]+)"
+
+ let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
+ let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
+
+ let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
+ let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
+
+ // Combine and sort matches
+ var allMatches: [(range: NSRange, type: String)] = []
+ for match in hashtagMatches {
+ allMatches.append((match.range(at: 0), "hashtag"))
+ }
+ for match in mentionMatches {
+ allMatches.append((match.range(at: 0), "mention"))
+ }
+ allMatches.sort { $0.range.location < $1.range.location }
+
+ // Build content with styling
+ var lastEnd = content.startIndex
+ let isMentioned = message.mentions?.contains(nickname) ?? false
+
+ for (range, type) in allMatches {
+ // Add text before match
+ if let nsRange = Range(range, in: content) {
+ let beforeText = String(content[lastEnd.. AttributedString {
var result = AttributedString()
@@ -1327,15 +1591,15 @@ class ChatViewModel: ObservableObject {
let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
var timestampStyle = AttributeContainer()
- timestampStyle.foregroundColor = secondaryColor
+ timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
timestampStyle.font = .system(size: 12, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
if message.sender == "system" {
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
- contentStyle.foregroundColor = secondaryColor
- contentStyle.font = .system(size: 14, design: .monospaced).italic()
+ contentStyle.foregroundColor = Color.gray
+ contentStyle.font = .system(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
} else {
let sender = AttributedString("<\(message.sender)> ")
@@ -1415,63 +1679,63 @@ class ChatViewModel: ObservableObject {
}
extension ChatViewModel: BitchatDelegate {
- func didReceiveRoomLeave(_ room: String, from peerID: String) {
- // Remove peer from room members
- if roomMembers[room] != nil {
- roomMembers[room]?.remove(peerID)
+ func didReceiveChannelLeave(_ channel: String, from peerID: String) {
+ // Remove peer from channel members
+ if channelMembers[channel] != nil {
+ channelMembers[channel]?.remove(peerID)
// Force UI update
objectWillChange.send()
}
}
- func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
- let wasAlreadyProtected = passwordProtectedRooms.contains(room)
+ func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
+ let wasAlreadyProtected = passwordProtectedChannels.contains(channel)
if isProtected {
- passwordProtectedRooms.insert(room)
+ passwordProtectedChannels.insert(channel)
if let creator = creatorID {
- roomCreators[room] = creator
+ channelCreators[channel] = creator
}
// Store the key commitment if provided
if let commitment = keyCommitment {
- roomKeyCommitments[room] = commitment
+ channelKeyCommitments[channel] = commitment
}
- // If we just learned this room is protected and we're in it without a key, prompt for password
- if !wasAlreadyProtected && joinedRooms.contains(room) && roomKeys[room] == nil {
+ // If we just learned this channel is protected and we're in it without a key, prompt for password
+ if !wasAlreadyProtected && joinedChannels.contains(channel) && channelKeys[channel] == nil {
// Add system message
let systemMessage = BitchatMessage(
sender: "system",
- content: "room \(room) is password protected. you need the password to participate.",
+ content: "channel \(channel) is password protected. you need the password to participate.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
- // If currently viewing this room, show password prompt
- if currentRoom == room {
- passwordPromptRoom = room
+ // If currently viewing this channel, show password prompt
+ if currentChannel == channel {
+ passwordPromptChannel = channel
showPasswordPrompt = true
}
}
} else {
- passwordProtectedRooms.remove(room)
- // If we're in this room and it's no longer protected, clear the key
- roomKeys.removeValue(forKey: room)
- roomPasswords.removeValue(forKey: room)
- roomKeyCommitments.removeValue(forKey: room)
+ passwordProtectedChannels.remove(channel)
+ // If we're in this channel and it's no longer protected, clear the key
+ channelKeys.removeValue(forKey: channel)
+ channelPasswords.removeValue(forKey: channel)
+ channelKeyCommitments.removeValue(forKey: channel)
}
- // Save updated room data
- saveRoomData()
+ // Save updated channel data
+ saveChannelData()
}
- func decryptRoomMessage(_ encryptedContent: Data, room: String, testKey: SymmetricKey? = nil) -> String? {
- let key = testKey ?? roomKeys[room]
+ func decryptChannelMessage(_ encryptedContent: Data, channel: String, testKey: SymmetricKey? = nil) -> String? {
+ let key = testKey ?? channelKeys[channel]
guard let key = key else {
return nil
}
@@ -1488,70 +1752,70 @@ extension ChatViewModel: BitchatDelegate {
}
}
- func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
+ func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
- // Only process if we're a member of this room
- guard joinedRooms.contains(room) else { return }
+ // Only process if we're a member of this channel
+ guard joinedChannels.contains(channel) else { return }
- // Verify the announcement is from the room owner
- if let creatorID = creatorID, roomCreators[room] != creatorID {
+ // Verify the announcement is from the channel owner
+ if let creatorID = creatorID, channelCreators[channel] != creatorID {
return
}
// Update retention status
if enabled {
- retentionEnabledRooms.insert(room)
- savedRooms.insert(room)
- // Ensure room is in favorites if not already
- if !MessageRetentionService.shared.getFavoriteRooms().contains(room) {
- _ = MessageRetentionService.shared.toggleFavoriteRoom(room)
+ retentionEnabledChannels.insert(channel)
+ savedChannels.insert(channel)
+ // Ensure channel is in favorites if not already
+ if !MessageRetentionService.shared.getFavoriteChannels().contains(channel) {
+ _ = MessageRetentionService.shared.toggleFavoriteChannel(channel)
}
// Show system message
let systemMessage = BitchatMessage(
sender: "system",
- content: "room owner enabled message retention for \(room). all messages will be saved locally.",
+ content: "channel owner enabled message retention for \(channel). all messages will be saved locally.",
timestamp: Date(),
isRelay: false
)
- if currentRoom == room {
+ if currentChannel == channel {
messages.append(systemMessage)
- } else if var roomMsgs = roomMessages[room] {
- roomMsgs.append(systemMessage)
- roomMessages[room] = roomMsgs
+ } else if var channelMsgs = channelMessages[channel] {
+ channelMsgs.append(systemMessage)
+ channelMessages[channel] = channelMsgs
} else {
- roomMessages[room] = [systemMessage]
+ channelMessages[channel] = [systemMessage]
}
} else {
- retentionEnabledRooms.remove(room)
- savedRooms.remove(room)
+ retentionEnabledChannels.remove(channel)
+ savedChannels.remove(channel)
- // Delete all saved messages for this room
- MessageRetentionService.shared.deleteMessagesForRoom(room)
+ // Delete all saved messages for this channel
+ MessageRetentionService.shared.deleteMessagesForChannel(channel)
// Remove from favorites if currently set
- if MessageRetentionService.shared.getFavoriteRooms().contains(room) {
- _ = MessageRetentionService.shared.toggleFavoriteRoom(room)
+ if MessageRetentionService.shared.getFavoriteChannels().contains(channel) {
+ _ = MessageRetentionService.shared.toggleFavoriteChannel(channel)
}
// Show system message
let systemMessage = BitchatMessage(
sender: "system",
- content: "room owner disabled message retention for \(room). all saved messages have been deleted.",
+ content: "channel owner disabled message retention for \(channel). all saved messages have been deleted.",
timestamp: Date(),
isRelay: false
)
- if currentRoom == room {
+ if currentChannel == channel {
messages.append(systemMessage)
- } else if var roomMsgs = roomMessages[room] {
- roomMsgs.append(systemMessage)
- roomMessages[room] = roomMsgs
+ } else if var channelMsgs = channelMessages[channel] {
+ channelMsgs.append(systemMessage)
+ channelMessages[channel] = channelMsgs
} else {
- roomMessages[room] = [systemMessage]
+ channelMessages[channel] = [systemMessage]
}
}
// Persist retention status
- userDefaults.set(Array(retentionEnabledRooms), forKey: retentionEnabledRoomsKey)
+ userDefaults.set(Array(retentionEnabledChannels), forKey: retentionEnabledChannelsKey)
}
private func handleCommand(_ command: String) {
@@ -1559,36 +1823,36 @@ extension ChatViewModel: BitchatDelegate {
guard let cmd = parts.first else { return }
switch cmd {
- case "/j":
+ case "/j", "/join":
if parts.count > 1 {
- let roomName = String(parts[1])
- // Ensure room name starts with #
- let room = roomName.hasPrefix("#") ? roomName : "#\(roomName)"
+ let channelName = String(parts[1])
+ // Ensure channel name starts with #
+ let channel = channelName.hasPrefix("#") ? channelName : "#\(channelName)"
- // Validate room name
- let cleanedName = room.dropFirst()
+ // Validate channel name
+ let cleanedName = channel.dropFirst()
let isValidName = !cleanedName.isEmpty && cleanedName.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
if !isValidName {
let systemMessage = BitchatMessage(
sender: "system",
- content: "invalid room name. use only letters, numbers, and underscores.",
+ content: "invalid channel name. use only letters, numbers, and underscores.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
} else {
- let wasAlreadyJoined = joinedRooms.contains(room)
- let wasPasswordProtected = passwordProtectedRooms.contains(room)
- let hadCreator = roomCreators[room] != nil
+ let wasAlreadyJoined = joinedChannels.contains(channel)
+ let wasPasswordProtected = passwordProtectedChannels.contains(channel)
+ let hadCreator = channelCreators[channel] != nil
- let success = joinRoom(room)
+ let success = joinChannel(channel)
if success {
if !wasAlreadyJoined {
- var message = "joined room \(room)"
+ var message = "joined channel \(channel)"
if !hadCreator && !wasPasswordProtected {
- message += " (created new room - you are the owner)"
+ message += " (created new channel - you are the owner)"
}
let systemMessage = BitchatMessage(
sender: "system",
@@ -1598,10 +1862,10 @@ extension ChatViewModel: BitchatDelegate {
)
messages.append(systemMessage)
} else {
- // Already in room, just switched to it
+ // Already in channel, just switched to it
let systemMessage = BitchatMessage(
sender: "system",
- content: "switched to room \(room)",
+ content: "switched to channel \(channel)",
timestamp: Date(),
isRelay: false
)
@@ -1614,7 +1878,7 @@ extension ChatViewModel: BitchatDelegate {
// Show usage hint
let systemMessage = BitchatMessage(
sender: "system",
- content: "usage: /j #roomname",
+ content: "usage: /j #channelname",
timestamp: Date(),
isRelay: false
)
@@ -1624,12 +1888,12 @@ extension ChatViewModel: BitchatDelegate {
// /create is now just an alias for /join
let systemMessage = BitchatMessage(
sender: "system",
- content: "use /join #roomname to join or create a room",
+ content: "use /join #channelname to join or create a channel",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
- case "/m":
+ case "/m", "/msg":
if parts.count > 1 {
let targetName = String(parts[1])
// Remove @ if present
@@ -1670,57 +1934,57 @@ extension ChatViewModel: BitchatDelegate {
)
messages.append(systemMessage)
}
- case "/rooms":
- // Discover all rooms (both joined and not joined)
- var allRooms: Set = Set()
+ case "/channels":
+ // Discover all channels (both joined and not joined)
+ var allChannels: Set = Set()
- // Add joined rooms
- allRooms.formUnion(joinedRooms)
+ // Add joined channels
+ allChannels.formUnion(joinedChannels)
- // Find rooms from messages we've seen
+ // Find channels from messages we've seen
for msg in messages {
- if let room = msg.room {
- allRooms.insert(room)
+ if let channel = msg.channel {
+ allChannels.insert(channel)
}
}
- // Also check room messages we've cached
- for (room, _) in roomMessages {
- allRooms.insert(room)
+ // Also check channel messages we've cached
+ for (channel, _) in channelMessages {
+ allChannels.insert(channel)
}
- // Add password protected rooms we know about
- allRooms.formUnion(passwordProtectedRooms)
+ // Add password protected channels we know about
+ allChannels.formUnion(passwordProtectedChannels)
- if allRooms.isEmpty {
+ if allChannels.isEmpty {
let systemMessage = BitchatMessage(
sender: "system",
- content: "no rooms discovered yet. rooms appear as people use them.",
+ content: "no channels discovered yet. channels appear as people use them.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
} else {
- let roomList = allRooms.sorted().map { room in
+ let channelList = allChannels.sorted().map { channel in
var status = ""
- if joinedRooms.contains(room) {
+ if joinedChannels.contains(channel) {
status += " ✓"
}
- if passwordProtectedRooms.contains(room) {
+ if passwordProtectedChannels.contains(channel) {
status += " 🔒"
}
- if retentionEnabledRooms.contains(room) {
+ if retentionEnabledChannels.contains(channel) {
status += " 📌"
}
- if roomCreators[room] == meshService.myPeerID {
+ if channelCreators[channel] == meshService.myPeerID {
status += " (owner)"
}
- return "\(room)\(status)"
+ return "\(channel)\(status)"
}.joined(separator: "\n")
let systemMessage = BitchatMessage(
sender: "system",
- content: "discovered rooms:\n\(roomList)\n\n✓ = joined, 🔒 = password protected, 📌 = retention enabled",
+ content: "discovered channels:\n\(channelList)\n\n✓ = joined, 🔒 = password protected, 📌 = retention enabled",
timestamp: Date(),
isRelay: false
)
@@ -1750,7 +2014,7 @@ extension ChatViewModel: BitchatDelegate {
messages.append(systemMessage)
}
case "/transfer":
- // Transfer room ownership
+ // Transfer channel ownership
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
if parts.count < 2 {
let systemMessage = BitchatMessage(
@@ -1761,14 +2025,14 @@ extension ChatViewModel: BitchatDelegate {
)
messages.append(systemMessage)
} else {
- transferRoomOwnership(to: parts[1])
+ transferChannelOwnership(to: parts[1])
}
case "/pass":
- // Change room password (only available in rooms)
- guard currentRoom != nil else {
+ // Change channel password (only available in channels)
+ guard currentChannel != nil else {
let systemMessage = BitchatMessage(
sender: "system",
- content: "you must be in a room to use /pass.",
+ content: "you must be in a channel to use /pass.",
timestamp: Date(),
isRelay: false
)
@@ -1785,13 +2049,13 @@ extension ChatViewModel: BitchatDelegate {
)
messages.append(systemMessage)
} else {
- changeRoomPassword(to: parts[1])
+ changeChannelPassword(to: parts[1])
}
case "/clear":
// Clear messages based on current context
- if let room = currentRoom {
- // Clear room messages
- roomMessages[room]?.removeAll()
+ if let channel = currentChannel {
+ // Clear channel messages
+ channelMessages[channel]?.removeAll()
} else if let peerID = selectedPrivateChatPeer {
// Clear private chat
privateChats[peerID]?.removeAll()
@@ -1800,11 +2064,11 @@ extension ChatViewModel: BitchatDelegate {
messages.removeAll()
}
case "/save":
- // Toggle retention for current room (owner only)
- guard let room = currentRoom else {
+ // Toggle retention for current channel (owner only)
+ guard let channel = currentChannel else {
let systemMessage = BitchatMessage(
sender: "system",
- content: "you must be in a room to toggle message retention.",
+ content: "you must be in a channel to toggle message retention.",
timestamp: Date(),
isRelay: false
)
@@ -1812,11 +2076,11 @@ extension ChatViewModel: BitchatDelegate {
break
}
- // Check if user is the room owner
- guard roomCreators[room] == meshService.myPeerID else {
+ // Check if user is the channel owner
+ guard channelCreators[channel] == meshService.myPeerID else {
let systemMessage = BitchatMessage(
sender: "system",
- content: "only the room owner can toggle message retention.",
+ content: "only the channel owner can toggle message retention.",
timestamp: Date(),
isRelay: false
)
@@ -1825,65 +2089,347 @@ extension ChatViewModel: BitchatDelegate {
}
// Toggle retention status
- let isEnabling = !retentionEnabledRooms.contains(room)
+ let isEnabling = !retentionEnabledChannels.contains(channel)
if isEnabling {
- // Enable retention for this room
- retentionEnabledRooms.insert(room)
- savedRooms.insert(room)
- _ = MessageRetentionService.shared.toggleFavoriteRoom(room) // Enable if not already
+ // Enable retention for this channel
+ retentionEnabledChannels.insert(channel)
+ savedChannels.insert(channel)
+ _ = MessageRetentionService.shared.toggleFavoriteChannel(channel) // Enable if not already
// Announce to all members that retention is enabled
- meshService.sendRoomRetentionAnnouncement(room, enabled: true)
+ meshService.sendChannelRetentionAnnouncement(channel, enabled: true)
let systemMessage = BitchatMessage(
sender: "system",
- content: "message retention enabled for room \(room). all members will save messages locally.",
+ content: "message retention enabled for channel \(channel). all members will save messages locally.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
// Load any previously saved messages
- let savedMessages = MessageRetentionService.shared.loadMessagesForRoom(room)
+ let savedMessages = MessageRetentionService.shared.loadMessagesForChannel(channel)
if !savedMessages.isEmpty {
// Merge saved messages with current messages, avoiding duplicates
- var existingMessageIDs = Set(roomMessages[room]?.map { $0.id } ?? [])
+ var existingMessageIDs = Set(channelMessages[channel]?.map { $0.id } ?? [])
for savedMessage in savedMessages {
if !existingMessageIDs.contains(savedMessage.id) {
- if roomMessages[room] == nil {
- roomMessages[room] = []
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
}
- roomMessages[room]?.append(savedMessage)
+ channelMessages[channel]?.append(savedMessage)
existingMessageIDs.insert(savedMessage.id)
}
}
// Sort by timestamp
- roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
+ channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
} else {
- // Disable retention for this room
- retentionEnabledRooms.remove(room)
- savedRooms.remove(room)
+ // Disable retention for this channel
+ retentionEnabledChannels.remove(channel)
+ savedChannels.remove(channel)
- // Delete all saved messages for this room
- MessageRetentionService.shared.deleteMessagesForRoom(room)
- _ = MessageRetentionService.shared.toggleFavoriteRoom(room) // Disable if enabled
+ // Delete all saved messages for this channel
+ MessageRetentionService.shared.deleteMessagesForChannel(channel)
+ _ = MessageRetentionService.shared.toggleFavoriteChannel(channel) // Disable if enabled
// Announce to all members that retention is disabled
- meshService.sendRoomRetentionAnnouncement(room, enabled: false)
+ meshService.sendChannelRetentionAnnouncement(channel, enabled: false)
let systemMessage = BitchatMessage(
sender: "system",
- content: "message retention disabled for room \(room). all saved messages will be deleted on all devices.",
+ content: "message retention disabled for channel \(channel). all saved messages will be deleted on all devices.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+
+ // Save the updated channel data
+ saveChannelData()
+ case "/hug":
+ if parts.count > 1 {
+ let targetName = String(parts[1])
+ // Remove @ if present
+ let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
+
+ // Check if target exists in connected peers
+ if let targetPeerID = getPeerIDForNickname(nickname) {
+ // Create hug message
+ let hugMessage = BitchatMessage(
+ sender: "system",
+ content: "🫂 \(self.nickname) hugs \(nickname)",
+ timestamp: Date(),
+ isRelay: false,
+ isPrivate: false,
+ recipientNickname: nickname,
+ senderPeerID: meshService.myPeerID,
+ channel: currentChannel
+ )
+
+ // Send as a regular message but it will be displayed as system message due to content
+ let hugContent = "* 🫂 \(self.nickname) hugs \(nickname) *"
+ if let channel = currentChannel {
+ meshService.sendMessage(hugContent, channel: channel)
+ // Add to channel messages
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
+ }
+ channelMessages[channel]?.append(hugMessage)
+ } else if selectedPrivateChatPeer != nil {
+ // In private chat, send as private message
+ if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
+ meshService.sendPrivateMessage("* 🫂 \(self.nickname) hugs you *", to: targetPeerID, recipientNickname: peerNickname)
+ }
+ } else {
+ // In public chat
+ meshService.sendMessage(hugContent)
+ messages.append(hugMessage)
+ }
+ } else {
+ let errorMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot hug \(nickname): user not found.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(errorMessage)
+ }
+ } else {
+ let usageMessage = BitchatMessage(
+ sender: "system",
+ content: "usage: /hug ",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(usageMessage)
+ }
+
+ case "/slap":
+ if parts.count > 1 {
+ let targetName = String(parts[1])
+ // Remove @ if present
+ let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
+
+ // Check if target exists in connected peers
+ if let targetPeerID = getPeerIDForNickname(nickname) {
+ // Create slap message
+ let slapMessage = BitchatMessage(
+ sender: "system",
+ content: "🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout",
+ timestamp: Date(),
+ isRelay: false,
+ isPrivate: false,
+ recipientNickname: nickname,
+ senderPeerID: meshService.myPeerID,
+ channel: currentChannel
+ )
+
+ // Send as a regular message but it will be displayed as system message due to content
+ let slapContent = "* 🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout *"
+ if let channel = currentChannel {
+ meshService.sendMessage(slapContent, channel: channel)
+ // Add to channel messages
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
+ }
+ channelMessages[channel]?.append(slapMessage)
+ } else if selectedPrivateChatPeer != nil {
+ // In private chat, send as private message
+ if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
+ meshService.sendPrivateMessage("* 🐟 \(self.nickname) slaps you around a bit with a large trout *", to: targetPeerID, recipientNickname: peerNickname)
+ }
+ } else {
+ // In public chat
+ meshService.sendMessage(slapContent)
+ messages.append(slapMessage)
+ }
+ } else {
+ let errorMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot slap \(nickname): user not found.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(errorMessage)
+ }
+ } else {
+ let usageMessage = BitchatMessage(
+ sender: "system",
+ content: "usage: /slap ",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(usageMessage)
+ }
+
+ case "/block":
+ if parts.count > 1 {
+ let targetName = String(parts[1])
+ // Remove @ if present
+ let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
+
+ // Find peer ID for this nickname
+ if let peerID = getPeerIDForNickname(nickname) {
+ // Get public key fingerprint for persistent blocking
+ if let publicKeyData = meshService.getPeerPublicKey(peerID) {
+ let fingerprint = SHA256.hash(data: publicKeyData)
+ .compactMap { String(format: "%02x", $0) }
+ .joined()
+ .prefix(16)
+ .lowercased()
+ let fingerprintStr = String(fingerprint)
+
+ if blockedUsers.contains(fingerprintStr) {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "\(nickname) is already blocked.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ } else {
+ blockedUsers.insert(fingerprintStr)
+ saveBlockedUsers()
+
+ // Remove from favorites if blocked
+ if favoritePeers.contains(fingerprintStr) {
+ favoritePeers.remove(fingerprintStr)
+ saveFavorites()
+ }
+
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "blocked \(nickname). you will no longer receive messages from them.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot block \(nickname): unable to verify identity.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot block \(nickname): user not found.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ // List blocked users
+ if blockedUsers.isEmpty {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "no blocked peers.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ } else {
+ // Find nicknames for blocked users
+ var blockedNicknames: [String] = []
+ for (peerID, _) in meshService.getPeerNicknames() {
+ if let publicKeyData = meshService.getPeerPublicKey(peerID) {
+ let fingerprint = SHA256.hash(data: publicKeyData)
+ .compactMap { String(format: "%02x", $0) }
+ .joined()
+ .prefix(16)
+ .lowercased()
+ let fingerprintStr = String(fingerprint)
+ if blockedUsers.contains(fingerprintStr) {
+ if let nickname = meshService.getPeerNicknames()[peerID] {
+ blockedNicknames.append(nickname)
+ }
+ }
+ }
+ }
+
+ let blockedList = blockedNicknames.isEmpty ? "blocked peers (not currently online)" : blockedNicknames.sorted().joined(separator: ", ")
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "blocked peers: \(blockedList)",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ }
+
+ case "/unblock":
+ if parts.count > 1 {
+ let targetName = String(parts[1])
+ // Remove @ if present
+ let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
+
+ // Find peer ID for this nickname
+ if let peerID = getPeerIDForNickname(nickname) {
+ // Get public key fingerprint
+ if let publicKeyData = meshService.getPeerPublicKey(peerID) {
+ let fingerprint = SHA256.hash(data: publicKeyData)
+ .compactMap { String(format: "%02x", $0) }
+ .joined()
+ .prefix(16)
+ .lowercased()
+ let fingerprintStr = String(fingerprint)
+
+ if blockedUsers.contains(fingerprintStr) {
+ blockedUsers.remove(fingerprintStr)
+ saveBlockedUsers()
+
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "unblocked \(nickname).",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "\(nickname) is not blocked.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot unblock \(nickname): unable to verify identity.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "cannot unblock \(nickname): user not found.",
+ timestamp: Date(),
+ isRelay: false
+ )
+ messages.append(systemMessage)
+ }
+ } else {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: "usage: /unblock ",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
}
- // Save the updated room data
- saveRoomData()
default:
// Unknown command
let systemMessage = BitchatMessage(
@@ -1898,6 +2444,19 @@ extension ChatViewModel: BitchatDelegate {
func didReceiveMessage(_ message: BitchatMessage) {
+ // Check if sender is blocked (for both private and public messages)
+ if let senderPeerID = message.senderPeerID {
+ if isPeerBlocked(senderPeerID) {
+ // Silently ignore messages from blocked users
+ return
+ }
+ } else if let peerID = getPeerIDForNickname(message.sender) {
+ if isPeerBlocked(peerID) {
+ // Silently ignore messages from blocked users
+ return
+ }
+ }
+
if message.isPrivate {
// Handle private message
@@ -1953,6 +2512,29 @@ extension ChatViewModel: BitchatDelegate {
}
}
+ // Check if this is an action that should be converted to system message
+ let isActionMessage = messageToStore.content.hasPrefix("* ") && messageToStore.content.hasSuffix(" *") &&
+ (messageToStore.content.contains("🫂") || messageToStore.content.contains("🐟") ||
+ messageToStore.content.contains("took a screenshot"))
+
+ if isActionMessage {
+ // Convert to system message
+ messageToStore = BitchatMessage(
+ id: messageToStore.id,
+ sender: "system",
+ content: String(messageToStore.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
+ timestamp: messageToStore.timestamp,
+ isRelay: messageToStore.isRelay,
+ originalSender: messageToStore.originalSender,
+ isPrivate: messageToStore.isPrivate,
+ recipientNickname: messageToStore.recipientNickname,
+ senderPeerID: messageToStore.senderPeerID,
+ mentions: messageToStore.mentions,
+ channel: messageToStore.channel,
+ deliveryStatus: messageToStore.deliveryStatus
+ )
+ }
+
privateChats[peerID]?.append(messageToStore)
// Sort messages by timestamp to ensure proper ordering
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
@@ -1985,48 +2567,48 @@ extension ChatViewModel: BitchatDelegate {
} else if message.sender == nickname {
// Our own message that was echoed back - ignore it since we already added it locally
}
- } else if let room = message.room {
- // Room message
+ } else if let channel = message.channel {
+ // Channel message
- // Only process room messages if we've joined this room
- if joinedRooms.contains(room) {
+ // Only process channel messages if we've joined this channel
+ if joinedChannels.contains(channel) {
// Prepare the message to add (might be updated if decryption succeeds)
var messageToAdd = message
// Check if this is an encrypted message and we don't have the key
- if message.isEncrypted && roomKeys[room] == nil {
- // Mark room as password protected if not already
- let wasNewlyDiscovered = !passwordProtectedRooms.contains(room)
+ if message.isEncrypted && channelKeys[channel] == nil {
+ // Mark channel as password protected if not already
+ let wasNewlyDiscovered = !passwordProtectedChannels.contains(channel)
if wasNewlyDiscovered {
- passwordProtectedRooms.insert(room)
- saveRoomData()
+ passwordProtectedChannels.insert(channel)
+ saveChannelData()
- // Add a system message to indicate the room is password protected (only once)
+ // Add a system message to indicate the channel is password protected (only once)
let systemMessage = BitchatMessage(
sender: "system",
- content: "room \(room) is password protected. you need the password to read messages.",
+ content: "channel \(channel) is password protected. you need the password to read messages.",
timestamp: Date(),
isRelay: false
)
- if roomMessages[room] == nil {
- roomMessages[room] = []
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
}
- roomMessages[room]?.append(systemMessage)
+ channelMessages[channel]?.append(systemMessage)
}
- // If we're currently viewing this room, prompt for password
- if currentRoom == room {
- passwordPromptRoom = room
+ // If we're currently viewing this channel, prompt for password
+ if currentChannel == channel {
+ passwordPromptChannel = channel
showPasswordPrompt = true
}
- } else if message.isEncrypted && roomKeys[room] != nil && message.content == "[Encrypted message - password required]" {
+ } else if message.isEncrypted && channelKeys[channel] != nil && message.content == "[Encrypted message - password required]" {
// We have a key but the message shows as encrypted - try to decrypt it again
- // Check if this is the first encrypted message in the room (password verification opportunity)
- let isFirstEncryptedMessage = roomMessages[room]?.filter { $0.isEncrypted }.isEmpty ?? true
+ // Check if this is the first encrypted message in the channel (password verification opportunity)
+ let isFirstEncryptedMessage = channelMessages[channel]?.filter { $0.isEncrypted }.isEmpty ?? true
if let encryptedData = message.encryptedContent {
- if let decryptedContent = decryptRoomMessage(encryptedData, room: room) {
+ if let decryptedContent = decryptChannelMessage(encryptedData, channel: channel) {
// Successfully decrypted - update the message content
if isFirstEncryptedMessage {
@@ -2034,7 +2616,7 @@ extension ChatViewModel: BitchatDelegate {
// Add success message
let verifiedMsg = BitchatMessage(
sender: "system",
- content: "password verified successfully for room \(room).",
+ content: "password verified successfully for channel \(channel).",
timestamp: Date(),
isRelay: false
)
@@ -2052,7 +2634,7 @@ extension ChatViewModel: BitchatDelegate {
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions,
- room: message.room,
+ channel: message.channel,
encryptedContent: message.encryptedContent,
isEncrypted: message.isEncrypted
)
@@ -2063,30 +2645,30 @@ extension ChatViewModel: BitchatDelegate {
// Decryption really failed - wrong password
// Clear the wrong password
- roomKeys.removeValue(forKey: room)
- roomPasswords.removeValue(forKey: room)
+ channelKeys.removeValue(forKey: channel)
+ channelPasswords.removeValue(forKey: channel)
// If this was the first encrypted message, we need to kick the user out
if isFirstEncryptedMessage {
- // Leave the room
- joinedRooms.remove(room)
- saveJoinedRooms()
+ // Leave the channel
+ joinedChannels.remove(channel)
+ saveJoinedChannels()
- // Clear room data
- roomMessages.removeValue(forKey: room)
- roomMembers.removeValue(forKey: room)
- unreadRoomMessages.removeValue(forKey: room)
+ // Clear channel data
+ channelMessages.removeValue(forKey: channel)
+ channelMembers.removeValue(forKey: channel)
+ unreadChannelMessages.removeValue(forKey: channel)
- // If we're currently in this room, exit to main
- if currentRoom == room {
- currentRoom = nil
+ // If we're currently in this channel, exit to main
+ if currentChannel == channel {
+ currentChannel = nil
}
// Add error message
let errorMsg = BitchatMessage(
sender: "system",
- content: "wrong password for room \(room). you have been removed from the room.",
+ content: "wrong password for channel \(channel). you have been removed from the channel.",
timestamp: Date(),
isRelay: false
)
@@ -2099,100 +2681,157 @@ extension ChatViewModel: BitchatDelegate {
// Add system message for subsequent failures
let systemMessage = BitchatMessage(
sender: "system",
- content: "wrong password for room \(room). please enter the correct password.",
+ content: "wrong password for channel \(channel). please enter the correct password.",
timestamp: Date(),
isRelay: false
)
messages.append(systemMessage)
// Show password prompt again
- if currentRoom == room {
- passwordPromptRoom = room
+ if currentChannel == channel {
+ passwordPromptChannel = channel
showPasswordPrompt = true
}
}
}
}
- // Add to room messages (using potentially decrypted version)
- if roomMessages[room] == nil {
- roomMessages[room] = []
+ // Check if this is an action that should be converted to system message
+ let isActionMessage = messageToAdd.content.hasPrefix("* ") && messageToAdd.content.hasSuffix(" *") &&
+ (messageToAdd.content.contains("🫂") || messageToAdd.content.contains("🐟") ||
+ messageToAdd.content.contains("took a screenshot"))
+
+ let finalMessage: BitchatMessage
+ if isActionMessage {
+ // Convert to system message
+ finalMessage = BitchatMessage(
+ sender: "system",
+ content: String(messageToAdd.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
+ timestamp: messageToAdd.timestamp,
+ isRelay: messageToAdd.isRelay,
+ originalSender: messageToAdd.originalSender,
+ isPrivate: false,
+ recipientNickname: messageToAdd.recipientNickname,
+ senderPeerID: messageToAdd.senderPeerID,
+ mentions: messageToAdd.mentions,
+ channel: messageToAdd.channel
+ )
+ } else {
+ finalMessage = messageToAdd
+ }
+
+ // Add to channel messages (using potentially decrypted version)
+ if channelMessages[channel] == nil {
+ channelMessages[channel] = []
}
// Check if this is our own message being echoed back
- if messageToAdd.sender != nickname {
- roomMessages[room]?.append(messageToAdd)
- roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
- } else {
+ if finalMessage.sender != nickname && finalMessage.sender != "system" {
+ channelMessages[channel]?.append(finalMessage)
+ channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
+ } else if finalMessage.sender != "system" {
// Our own message - check if we already have it (by ID and content)
- let messageExists = roomMessages[room]?.contains { existingMsg in
+ let messageExists = channelMessages[channel]?.contains { existingMsg in
// Check by ID first
- if existingMsg.id == messageToAdd.id {
+ if existingMsg.id == finalMessage.id {
return true
}
// Check by content and sender with time window (within 1 second)
- if existingMsg.content == messageToAdd.content &&
- existingMsg.sender == messageToAdd.sender {
- let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(messageToAdd.timestamp))
+ if existingMsg.content == finalMessage.content &&
+ existingMsg.sender == finalMessage.sender {
+ let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
} ?? false
if !messageExists {
// This is a message we sent from another device or it's missing locally
- roomMessages[room]?.append(messageToAdd)
- roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
+ channelMessages[channel]?.append(finalMessage)
+ channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
+ } else {
+ // System message - always add
+ channelMessages[channel]?.append(finalMessage)
+ channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
}
- // Save message if room has retention enabled
- if retentionEnabledRooms.contains(room) {
- MessageRetentionService.shared.saveMessage(messageToAdd, forRoom: room)
+ // Save message if channel has retention enabled
+ if retentionEnabledChannels.contains(channel) {
+ MessageRetentionService.shared.saveMessage(messageToAdd, forChannel: channel)
}
- // Track room members - only track the sender as a member
- if roomMembers[room] == nil {
- roomMembers[room] = Set()
+ // Track channel members - only track the sender as a member
+ if channelMembers[channel] == nil {
+ channelMembers[channel] = Set()
}
if let senderPeerID = message.senderPeerID {
- roomMembers[room]?.insert(senderPeerID)
+ channelMembers[channel]?.insert(senderPeerID)
} else {
}
- // Update unread count if not currently viewing this room and it's not our own message
- if currentRoom != room && messageToAdd.sender != nickname {
- unreadRoomMessages[room] = (unreadRoomMessages[room] ?? 0) + 1
+ // Update unread count if not currently viewing this channel and it's not our own message
+ if currentChannel != channel && finalMessage.sender != nickname {
+ unreadChannelMessages[channel] = (unreadChannelMessages[channel] ?? 0) + 1
}
} else {
- // We're not in this room, ignore the message
+ // We're not in this channel, ignore the message
}
} else {
// Regular public message (main chat)
+
+ // Check if this is an action that should be converted to system message
+ let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") &&
+ (message.content.contains("🫂") || message.content.contains("🐟") ||
+ message.content.contains("took a screenshot"))
+
+ let finalMessage: BitchatMessage
+ if isActionMessage {
+ // Convert to system message
+ finalMessage = BitchatMessage(
+ sender: "system",
+ content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
+ timestamp: message.timestamp,
+ isRelay: message.isRelay,
+ originalSender: message.originalSender,
+ isPrivate: false,
+ recipientNickname: message.recipientNickname,
+ senderPeerID: message.senderPeerID,
+ mentions: message.mentions,
+ channel: message.channel
+ )
+ } else {
+ finalMessage = message
+ }
+
// Check if this is our own message being echoed back
- if message.sender != nickname {
- messages.append(message)
+ if finalMessage.sender != nickname && finalMessage.sender != "system" {
+ messages.append(finalMessage)
// Sort messages by timestamp to ensure proper ordering
messages.sort { $0.timestamp < $1.timestamp }
- } else {
+ } else if finalMessage.sender != "system" {
// Our own message - check if we already have it (by ID and content)
let messageExists = messages.contains { existingMsg in
// Check by ID first
- if existingMsg.id == message.id {
+ if existingMsg.id == finalMessage.id {
return true
}
// Check by content and sender with time window (within 1 second)
- if existingMsg.content == message.content &&
- existingMsg.sender == message.sender {
- let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(message.timestamp))
+ if existingMsg.content == finalMessage.content &&
+ existingMsg.sender == finalMessage.sender {
+ let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
}
if !messageExists {
// This is a message we sent from another device or it's missing locally
- messages.append(message)
+ messages.append(finalMessage)
messages.sort { $0.timestamp < $1.timestamp }
}
+ } else {
+ // System message - always add
+ messages.append(finalMessage)
+ messages.sort { $0.timestamp < $1.timestamp }
}
}
@@ -2208,7 +2847,62 @@ extension ChatViewModel: BitchatDelegate {
#if os(iOS)
// Haptic feedback for iOS only
- if isMentioned && message.sender != nickname {
+
+ // Check if this is a hug message directed at the user
+ let isHugForMe = message.content.contains("🫂") &&
+ (message.content.contains("hugs \(nickname)") ||
+ message.content.contains("hugs you"))
+
+ // Check if this is a slap message directed at the user
+ let isSlapForMe = message.content.contains("🐟") &&
+ (message.content.contains("slaps \(nickname) around") ||
+ message.content.contains("slaps you around"))
+
+ if isHugForMe && message.sender != nickname {
+ // Long warm haptic for hugs - continuous gentle vibration
+ let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
+ impactFeedback.prepare()
+
+ // Create a warm, sustained haptic pattern
+ for i in 0..<8 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) {
+ impactFeedback.impactOccurred()
+ }
+ }
+
+ // Add a final stronger pulse
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
+ let strongFeedback = UIImpactFeedbackGenerator(style: .heavy)
+ strongFeedback.prepare()
+ strongFeedback.impactOccurred()
+ }
+ } else if isSlapForMe && message.sender != nickname {
+ // Very harsh, fast, strong haptic for slaps - multiple sharp impacts
+ let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
+ impactFeedback.prepare()
+
+ // Rapid-fire heavy impacts to simulate a hard slap
+ impactFeedback.impactOccurred()
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
+ impactFeedback.impactOccurred()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
+ impactFeedback.impactOccurred()
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) {
+ impactFeedback.impactOccurred()
+ }
+
+ // Final extra heavy impact
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+ let finalImpact = UIImpactFeedbackGenerator(style: .heavy)
+ finalImpact.prepare()
+ finalImpact.impactOccurred()
+ }
+ } else if isMentioned && message.sender != nickname {
// Very prominent haptic for @mentions - triple tap with heavy impact
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
@@ -2272,14 +2966,14 @@ extension ChatViewModel: BitchatDelegate {
connectedPeers = peers
isConnected = !peers.isEmpty
- // Clean up room members who disconnected
- for (room, memberIDs) in roomMembers {
- // Remove disconnected peers from room members
+ // Clean up channel members who disconnected
+ for (channel, memberIDs) in channelMembers {
+ // Remove disconnected peers from channel members
let activeMembers = memberIDs.filter { memberID in
memberID == meshService.myPeerID || peers.contains(memberID)
}
if activeMembers != memberIDs {
- roomMembers[room] = activeMembers
+ channelMembers[channel] = activeMembers
}
}
@@ -2381,15 +3075,15 @@ extension ChatViewModel: BitchatDelegate {
self.objectWillChange.send()
}
- // Update in room messages
- for (room, var roomMsgs) in roomMessages {
- if let index = roomMsgs.firstIndex(where: { $0.id == messageID }) {
- let currentStatus = roomMsgs[index].deliveryStatus
+ // Update in channel messages
+ for (channel, var channelMsgs) in channelMessages {
+ if let index = channelMsgs.firstIndex(where: { $0.id == messageID }) {
+ let currentStatus = channelMsgs[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
- var updatedMessage = roomMsgs[index]
+ var updatedMessage = channelMsgs[index]
updatedMessage.deliveryStatus = status
- roomMsgs[index] = updatedMessage
- roomMessages[room] = roomMsgs
+ channelMsgs[index] = updatedMessage
+ channelMessages[channel] = channelMsgs
// Force UI update
DispatchQueue.main.async { [weak self] in
diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift
index d7524f2d..a261ecaf 100644
--- a/bitchat/Views/AppInfoView.swift
+++ b/bitchat/Views/AppInfoView.swift
@@ -65,11 +65,11 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users")
- FeatureRow(icon: "number", title: "Rooms",
- description: "Create #rooms for topic-based conversations")
+ FeatureRow(icon: "number", title: "Channels",
+ description: "Create #channels for topic-based conversations")
- FeatureRow(icon: "lock.fill", title: "Password Rooms",
- description: "Secure rooms with passwords and AES encryption")
+ FeatureRow(icon: "lock.fill", title: "Password Channels",
+ description: "Secure channels with passwords and AES encryption")
}
// Privacy
@@ -92,16 +92,36 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header")
- Text("• Swipe left or tap room name for sidebar")
+ Text("• Swipe left or tap channel name for sidebar")
Text("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone")
- Text("• Use #roomname to create/join rooms")
+ Text("• Use #channelname to create/join channels")
Text("• Triple-tap the logo for panic mode")
}
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
+ // Commands
+ VStack(alignment: .leading, spacing: 16) {
+ SectionHeader("Commands")
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("/j #channel - join or create a channel")
+ Text("/m @name - send private message")
+ Text("/w - see who's online")
+ Text("/channels - show all discovered channels")
+ Text("/block @name - block a peer")
+ Text("/block - list blocked peers")
+ Text("/unblock @name - unblock a peer")
+ Text("/clear - clear current chat")
+ Text("/hug @name - send someone a hug")
+ Text("/slap @name - slap with a trout")
+ }
+ .font(.system(size: 14, design: .monospaced))
+ .foregroundColor(textColor)
+ }
+
// Technical Details
VStack(alignment: .leading, spacing: 16) {
SectionHeader("Technical Details")
@@ -113,7 +133,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)")
- Text("Rooms: Password-protected with key commitments")
+ Text("Channels: Password-protected with key commitments")
Text("Storage: Keychain for passwords, encrypted retention")
}
.font(.system(size: 14, design: .monospaced))
@@ -171,11 +191,11 @@ struct AppInfoView: View {
FeatureRow(icon: "at", title: "Mentions",
description: "Use @nickname to notify specific users")
- FeatureRow(icon: "number", title: "Rooms",
- description: "Create #rooms for topic-based conversations")
+ FeatureRow(icon: "number", title: "Channels",
+ description: "Create #channels for topic-based conversations")
- FeatureRow(icon: "lock.fill", title: "Password Rooms",
- description: "Secure rooms with passwords and AES encryption")
+ FeatureRow(icon: "lock.fill", title: "Password Channels",
+ description: "Secure channels with passwords and AES encryption")
}
// Privacy
@@ -198,16 +218,36 @@ struct AppInfoView: View {
VStack(alignment: .leading, spacing: 8) {
Text("• Set your nickname in the header")
- Text("• Swipe left or tap room name for sidebar")
+ Text("• Swipe left or tap channel name for sidebar")
Text("• Tap a peer to start a private chat")
Text("• Use @nickname to mention someone")
- Text("• Use #roomname to create/join rooms")
+ Text("• Use #channelname to create/join channels")
Text("• Triple-tap the logo for panic mode")
}
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
+ // Commands
+ VStack(alignment: .leading, spacing: 16) {
+ SectionHeader("Commands")
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("/j #channel - join or create a channel")
+ Text("/m @name - send private message")
+ Text("/w - see who's online")
+ Text("/channels - show all discovered channels")
+ Text("/block @name - block a peer")
+ Text("/block - list blocked peers")
+ Text("/unblock @name - unblock a peer")
+ Text("/clear - clear current chat")
+ Text("/hug @name - send someone a hug")
+ Text("/slap @name - slap with a trout")
+ }
+ .font(.system(size: 14, design: .monospaced))
+ .foregroundColor(textColor)
+ }
+
// Technical Details
VStack(alignment: .leading, spacing: 16) {
SectionHeader("Technical Details")
@@ -219,7 +259,7 @@ struct AppInfoView: View {
Text("Store & Forward: 12h for all, ∞ for favorites")
Text("Battery: Adaptive scanning based on level")
Text("Platform: Universal (iOS, iPadOS, macOS)")
- Text("Rooms: Password-protected with key commitments")
+ Text("Channels: Password-protected with key commitments")
Text("Storage: Keychain for passwords, encrypted retention")
}
.font(.system(size: 14, design: .monospaced))
diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift
index 59923583..23e21ed8 100644
--- a/bitchat/Views/ContentView.swift
+++ b/bitchat/Views/ContentView.swift
@@ -19,7 +19,7 @@ struct ContentView: View {
@State private var sidebarDragOffset: CGFloat = 0
@State private var showAppInfo = false
@State private var showPasswordInput = false
- @State private var passwordInputRoom: String? = nil
+ @State private var passwordInputChannel: String? = nil
@State private var passwordInput = ""
@State private var showPasswordPrompt = false
@State private var passwordPromptInput = ""
@@ -112,55 +112,6 @@ struct ContentView: View {
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
}
}
-
- // Autocomplete overlay
- if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
- GeometryReader { geometry in
- VStack {
- Spacer()
- HStack {
- // Calculate approximate position based on nickname length and @ position
- let nicknameWidth: CGFloat = viewModel.selectedPrivateChatPeer != nil ? 90 : 80
- let charWidth: CGFloat = 8.5 // Approximate width of monospace character
- let atPosition = CGFloat(viewModel.autocompleteRange?.location ?? 0)
- let offsetX = nicknameWidth + (atPosition * charWidth)
-
- // Ensure offsetX is valid (not NaN or infinite)
- let safeOffsetX = offsetX.isFinite ? offsetX : nicknameWidth
-
- VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
- Button(action: {
- _ = viewModel.completeNickname(suggestion, in: &messageText)
- }) {
- HStack {
- Text("@\(suggestion)")
- .font(.system(size: 12, design: .monospaced))
- .foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
- Spacer()
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
- }
- .buttonStyle(.plain)
- }
- }
- .background(backgroundColor)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
- )
- .frame(width: 150, alignment: .leading)
- .offset(x: min(safeOffsetX, max(0, geometry.size.width - 180))) // Prevent going off-screen
- .padding(.bottom, 45) // Position just above input
-
- Spacer()
- }
- .padding(.horizontal, 12)
- }
- }
- }
}
#if os(macOS)
.frame(minWidth: 600, minHeight: 400)
@@ -168,34 +119,34 @@ struct ContentView: View {
.sheet(isPresented: $showAppInfo) {
AppInfoView()
}
- .alert("Set Room Password", isPresented: $showPasswordInput) {
+ .alert("Set Channel Password", isPresented: $showPasswordInput) {
SecureField("Password", text: $passwordInput)
Button("Cancel", role: .cancel) {
passwordInput = ""
- passwordInputRoom = nil
+ passwordInputChannel = nil
}
Button("Set Password") {
- if let room = passwordInputRoom, !passwordInput.isEmpty {
- viewModel.setRoomPassword(passwordInput, for: room)
+ if let channel = passwordInputChannel, !passwordInput.isEmpty {
+ viewModel.setChannelPassword(passwordInput, for: channel)
passwordInput = ""
- passwordInputRoom = nil
+ passwordInputChannel = nil
}
}
} message: {
- Text("Enter a password to protect \(passwordInputRoom ?? "room"). Others will need this password to read messages.")
+ Text("Enter a password to protect \(passwordInputChannel ?? "channel"). Others will need this password to read messages.")
}
- .alert("Enter Room Password", isPresented: Binding(
+ .alert("Enter Channel Password", isPresented: Binding(
get: { viewModel.showPasswordPrompt },
set: { viewModel.showPasswordPrompt = $0 }
)) {
SecureField("Password", text: $passwordPromptInput)
Button("Cancel", role: .cancel) {
passwordPromptInput = ""
- viewModel.passwordPromptRoom = nil
+ viewModel.passwordPromptChannel = nil
}
Button("Join") {
- if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty {
- let success = viewModel.joinRoom(room, password: passwordPromptInput)
+ if let channel = viewModel.passwordPromptChannel, !passwordPromptInput.isEmpty {
+ let success = viewModel.joinChannel(channel, password: passwordPromptInput)
if success {
passwordPromptInput = ""
} else {
@@ -206,7 +157,7 @@ struct ContentView: View {
}
}
} message: {
- Text("Room \(viewModel.passwordPromptRoom ?? "") is password protected. Enter the password to join.")
+ Text("Channel \(viewModel.passwordPromptChannel ?? "") is password protected. Enter the password to join.")
}
.alert("Wrong Password", isPresented: $showPasswordError) {
Button("OK", role: .cancel) { }
@@ -239,7 +190,7 @@ struct ContentView: View {
Image(systemName: "lock.fill")
.font(.system(size: 14))
.foregroundColor(Color.orange)
- Text("private: \(privatePeerNick)")
+ Text("\(privatePeerNick)")
.font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
}
@@ -256,10 +207,10 @@ struct ContentView: View {
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
- } else if let currentRoom = viewModel.currentRoom {
- // Room header
+ } else if let currentChannel = viewModel.currentChannel {
+ // Channel header
Button(action: {
- viewModel.switchToRoom(nil)
+ viewModel.switchToChannel(nil)
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
@@ -280,14 +231,14 @@ struct ContentView: View {
}
}) {
HStack(spacing: 6) {
- if viewModel.passwordProtectedRooms.contains(currentRoom) {
+ if viewModel.passwordProtectedChannels.contains(currentChannel) {
Image(systemName: "lock.fill")
.font(.system(size: 14))
.foregroundColor(Color.orange)
}
- Text("room: \(currentRoom)")
+ Text("channel: \(currentChannel)")
.font(.system(size: 16, weight: .medium, design: .monospaced))
- .foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.orange : Color.blue)
+ .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue)
}
}
.buttonStyle(.plain)
@@ -297,48 +248,48 @@ struct ContentView: View {
HStack(spacing: 8) {
// Show retention indicator for all users
- if viewModel.retentionEnabledRooms.contains(currentRoom) {
+ if viewModel.retentionEnabledChannels.contains(currentChannel) {
Image(systemName: "bookmark.fill")
.font(.system(size: 16))
.foregroundColor(Color.yellow)
- .help("Messages in this room are being saved locally")
+ .help("Messages in this channel are being saved locally")
}
- // Save button - only for room owner
- if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
+ // Save button - only for channel owner
+ if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: {
viewModel.sendMessage("/save")
}) {
- Image(systemName: viewModel.retentionEnabledRooms.contains(currentRoom) ? "bookmark.slash" : "bookmark")
+ Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
.font(.system(size: 16))
.foregroundColor(textColor)
}
.buttonStyle(.plain)
- .help(viewModel.retentionEnabledRooms.contains(currentRoom) ? "Disable message retention" : "Enable message retention")
+ .help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
}
- // Password button for room creator only
- if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
+ // Password button for channel creator only
+ if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
Button(action: {
// Toggle password protection
- if viewModel.passwordProtectedRooms.contains(currentRoom) {
- viewModel.removeRoomPassword(for: currentRoom)
+ if viewModel.passwordProtectedChannels.contains(currentChannel) {
+ viewModel.removeChannelPassword(for: currentChannel)
} else {
// Show password input
showPasswordInput = true
- passwordInputRoom = currentRoom
+ passwordInputChannel = currentChannel
}
}) {
- Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.fill" : "lock")
+ Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock")
.font(.system(size: 16))
- .foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor)
+ .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
}
- // Leave room button
+ // Leave channel button
Button(action: {
- viewModel.leaveRoom(currentRoom)
+ viewModel.leaveChannel(currentChannel)
}) {
Text("leave")
.font(.system(size: 12, design: .monospaced))
@@ -384,10 +335,10 @@ struct ContentView: View {
// People counter with unread indicator
HStack(spacing: 4) {
- // Check for any unread room messages
- let hasUnreadRoomMessages = viewModel.unreadRoomMessages.values.contains { $0 > 0 }
+ // Check for any unread channel messages
+ let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
- if hasUnreadRoomMessages {
+ if hasUnreadChannelMessages {
Image(systemName: "number")
.font(.system(size: 12))
.foregroundColor(Color.blue)
@@ -400,17 +351,26 @@ struct ContentView: View {
}
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
- let roomCount = viewModel.joinedRooms.count
- let statusText = if !viewModel.isConnected {
- "alone :/"
- } else if roomCount > 0 {
- "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")"
- } else {
- "\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")"
+ let channelCount = viewModel.joinedChannels.count
+
+ HStack(spacing: 4) {
+ // People icon with count
+ Image(systemName: "person.2.fill")
+ .font(.system(size: 11))
+ Text("\(otherPeersCount)")
+ .font(.system(size: 12, design: .monospaced))
+
+ // Channels icon with count (only if there are channels)
+ if channelCount > 0 {
+ Text("·")
+ .font(.system(size: 12, design: .monospaced))
+ Image(systemName: "square.split.2x2")
+ .font(.system(size: 11))
+ Text("\(channelCount)")
+ .font(.system(size: 12, design: .monospaced))
+ }
}
- Text(statusText)
- .font(.system(size: 12, design: .monospaced))
- .foregroundColor(viewModel.isConnected ? textColor : Color.red)
+ .foregroundColor(viewModel.isConnected ? textColor : Color.red)
}
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
@@ -435,8 +395,8 @@ struct ContentView: View {
// Log what we're showing
// Removed debug logging
return msgs
- } else if let currentRoom = viewModel.currentRoom {
- return viewModel.getRoomMessages(currentRoom)
+ } else if let currentChannel = viewModel.currentChannel {
+ return viewModel.getChannelMessages(currentChannel)
} else {
return viewModel.messages
}
@@ -445,64 +405,29 @@ struct ContentView: View {
ForEach(messages, id: \.id) { message in
VStack(alignment: .leading, spacing: 4) {
// Check if current user is mentioned
- let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
+ let _ = message.mentions?.contains(viewModel.nickname) ?? false
if message.sender == "system" {
// System messages
- Text(viewModel.formatMessage(message, colorScheme: colorScheme))
- .font(.system(size: 14, design: .monospaced))
+ Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
+ .textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
- .textSelection(.enabled)
} else {
- // Regular messages with tappable sender name
- HStack(alignment: .center, spacing: 0) {
- // Timestamp
- Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
- .font(.system(size: 14, design: .monospaced))
- .foregroundColor(secondaryTextColor)
+ // Regular messages with natural text wrapping
+ HStack(alignment: .top, spacing: 0) {
+ // Single text view for natural wrapping
+ Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
-
- // Tappable sender name
- if message.sender != viewModel.nickname {
- Button(action: {
- if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
- viewModel.startPrivateChat(with: peerID)
- }
- }) {
- let senderColor = viewModel.getSenderColor(for: message, colorScheme: colorScheme)
- Text("<@\(message.sender)>")
- .font(.system(size: 14, weight: .medium, design: .monospaced))
- .foregroundColor(senderColor)
- }
- .buttonStyle(.plain)
- } else {
- // Own messages not tappable
- Text("<@\(message.sender)>")
- .font(.system(size: 14, weight: .medium, design: .monospaced))
- .foregroundColor(textColor)
- .textSelection(.enabled)
- }
-
- Text(" ")
-
- // Message content with clickable hashtags
- MessageContentView(
- message: message,
- viewModel: viewModel,
- colorScheme: colorScheme,
- isMentioned: isMentioned
- )
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status, colorScheme: colorScheme)
.padding(.leading, 4)
- .alignmentGuide(.firstTextBaseline) { _ in 12 }
}
-
- Spacer()
}
}
}
@@ -559,45 +484,19 @@ struct ContentView: View {
private var inputView: some View {
VStack(spacing: 0) {
- // Command suggestions
- if showCommandSuggestions && !commandSuggestions.isEmpty {
+ // @mentions autocomplete
+ if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
- let baseCommands: [String: String] = [
- "/j": "join or create a room",
- "/rooms": "show all discovered rooms",
- "/w": "see who's online",
- "/m": "send private message",
- "/clear": "clear chat messages"
- ]
-
- let roomCommands: [String: String] = [
- "/transfer": "transfer room ownership",
- "/pass": "change room password",
- "/save": "save room messages locally"
- ]
-
- let commandDescriptions = viewModel.currentRoom != nil
- ? baseCommands.merging(roomCommands) { (_, new) in new }
- : baseCommands
-
- ForEach(commandSuggestions, id: \.self) { command in
+ ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
Button(action: {
- // Replace current text with selected command
- messageText = command + " "
- showCommandSuggestions = false
- commandSuggestions = []
+ _ = viewModel.completeNickname(suggestion, in: &messageText)
}) {
HStack {
- Text(command)
+ Text("@\(suggestion)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
Spacer()
- if let description = commandDescriptions[command] {
- Text(description)
- .font(.system(size: 10, design: .monospaced))
- .foregroundColor(secondaryTextColor)
- }
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
@@ -613,7 +512,81 @@ struct ContentView: View {
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.padding(.horizontal, 12)
- .padding(.bottom, 4)
+ }
+
+ // Command suggestions
+ if showCommandSuggestions && !commandSuggestions.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ // Define commands with aliases and syntax
+ let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
+ (["/block"], "[nickname]", "block or list blocked peers"),
+ (["/clear"], nil, "clear chat messages"),
+ (["/hug"], "", "send someone a warm hug"),
+ (["/j", "/join"], "", "join or create a channel"),
+ (["/m", "/msg"], " [message]", "send private message"),
+ (["/channels"], nil, "show all discovered channels"),
+ (["/slap"], "", "slap someone with a trout"),
+ (["/unblock"], "", "unblock a peer"),
+ (["/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"], "", "transfer channel ownership")
+ ]
+
+ // Build the display
+ let allCommands = viewModel.currentChannel != nil
+ ? commandInfo + channelCommandInfo
+ : commandInfo
+
+ // Show matching commands
+ ForEach(commandSuggestions, id: \.self) { command in
+ // Find the command info for this suggestion
+ if let info = allCommands.first(where: { $0.commands.contains(command) }) {
+ Button(action: {
+ // Replace current text with selected command
+ messageText = command + " "
+ showCommandSuggestions = false
+ commandSuggestions = []
+ }) {
+ HStack {
+ // Show all aliases together
+ Text(info.commands.joined(separator: ", "))
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundColor(textColor)
+ .fontWeight(.medium)
+
+ // Show syntax if any
+ if let syntax = info.syntax {
+ Text(syntax)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(secondaryTextColor.opacity(0.8))
+ }
+
+ Spacer()
+
+ // Show description
+ Text(info.description)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(secondaryTextColor)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 3)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .background(Color.gray.opacity(0.1))
+ }
+ }
+ }
+ .background(backgroundColor)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
+ )
+ .padding(.horizontal, 12)
}
HStack(alignment: .center, spacing: 4) {
@@ -624,7 +597,7 @@ struct ContentView: View {
.lineLimit(1)
.fixedSize()
.padding(.leading, 12)
- } else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
+ } else if let currentChannel = viewModel.currentChannel, viewModel.passwordProtectedChannels.contains(currentChannel) {
Text("<@\(viewModel.nickname)> →")
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
@@ -654,24 +627,48 @@ struct ContentView: View {
if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list
var commandDescriptions = [
- ("/j", "join or create a room"),
- ("/rooms", "show all discovered rooms"),
- ("/w", "see who's online"),
+ ("/block", "block or list blocked peers"),
+ ("/channels", "show all discovered channels"),
+ ("/clear", "clear chat messages"),
+ ("/hug", "send someone a warm hug"),
+ ("/j", "join or create a channel"),
("/m", "send private message"),
- ("/clear", "clear chat messages")
+ ("/slap", "slap someone with a trout"),
+ ("/unblock", "unblock a peer"),
+ ("/w", "see who's online")
]
- // Add room-specific commands if in a room
- if viewModel.currentRoom != nil {
- commandDescriptions.append(("/transfer", "transfer room ownership"))
- commandDescriptions.append(("/pass", "change room password"))
- commandDescriptions.append(("/save", "save room messages locally"))
+ // Add channel-specific commands if in a channel
+ if viewModel.currentChannel != nil {
+ commandDescriptions.append(("/pass", "change channel password"))
+ commandDescriptions.append(("/save", "save channel messages locally"))
+ commandDescriptions.append(("/transfer", "transfer channel ownership"))
}
let input = newValue.lowercased()
+
+ // Map of aliases to primary commands
+ let aliases: [String: String] = [
+ "/join": "/j",
+ "/msg": "/m"
+ ]
+
+ // Filter commands, but convert aliases to primary
commandSuggestions = commandDescriptions
.filter { $0.0.starts(with: input) }
.map { $0.0 }
+
+ // Also check if input matches an alias
+ for (alias, primary) in aliases {
+ if alias.starts(with: input) && !commandSuggestions.contains(primary) {
+ if commandDescriptions.contains(where: { $0.0 == primary }) {
+ commandSuggestions.append(primary)
+ }
+ }
+ }
+
+ // Remove duplicates and sort
+ commandSuggestions = Array(Set(commandSuggestions)).sorted()
showCommandSuggestions = !commandSuggestions.isEmpty
} else {
showCommandSuggestions = false
@@ -686,7 +683,7 @@ struct ContentView: View {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 20))
.foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
- (viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? "")))
+ (viewModel.currentChannel != nil && viewModel.passwordProtectedChannels.contains(viewModel.currentChannel ?? "")))
? Color.orange : textColor)
}
.buttonStyle(.plain)
@@ -705,6 +702,128 @@ struct ContentView: View {
messageText = ""
}
+ @ViewBuilder
+ private var channelsSection: some View {
+ if !viewModel.joinedChannels.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 4) {
+ Image(systemName: "square.split.2x2")
+ .font(.system(size: 10))
+ Text("CHANNELS")
+ .font(.system(size: 11, weight: .bold, design: .monospaced))
+ }
+ .foregroundColor(secondaryTextColor)
+ .padding(.horizontal, 12)
+
+ ForEach(Array(viewModel.joinedChannels).sorted(), id: \.self) { channel in
+ channelButton(for: channel)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func channelButton(for channel: String) -> some View {
+ Button(action: {
+ // Check if channel needs password and we don't have it
+ if viewModel.passwordProtectedChannels.contains(channel) && viewModel.channelKeys[channel] == nil {
+ // Need password
+ viewModel.passwordPromptChannel = channel
+ viewModel.showPasswordPrompt = true
+ } else {
+ // Can enter channel
+ viewModel.switchToChannel(channel)
+ withAnimation(.spring()) {
+ showSidebar = false
+ }
+ }
+ }) {
+ HStack {
+ // Lock icon for password protected channels
+ if viewModel.passwordProtectedChannels.contains(channel) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 10))
+ .foregroundColor(secondaryTextColor)
+ }
+
+ Text(channel)
+ .font(.system(size: 14, design: .monospaced))
+ .foregroundColor(viewModel.currentChannel == channel ? Color.blue : textColor)
+
+ Spacer()
+
+ // Unread count
+ if let unreadCount = viewModel.unreadChannelMessages[channel], unreadCount > 0 {
+ Text("\(unreadCount)")
+ .font(.system(size: 10, weight: .bold, design: .monospaced))
+ .foregroundColor(backgroundColor)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.orange)
+ .clipShape(Capsule())
+ }
+
+ // Channel controls
+ if viewModel.currentChannel == channel {
+ channelControls(for: channel)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 4)
+ .background(viewModel.currentChannel == channel ? backgroundColor.opacity(0.5) : Color.clear)
+ }
+
+ @ViewBuilder
+ private func channelControls(for channel: String) -> some View {
+ HStack(spacing: 4) {
+ // Password button for channel creator only
+ if viewModel.channelCreators[channel] == viewModel.meshService.myPeerID {
+ Button(action: {
+ // Toggle password protection
+ if viewModel.passwordProtectedChannels.contains(channel) {
+ viewModel.removeChannelPassword(for: channel)
+ } else {
+ // Show password input
+ showPasswordInput = true
+ passwordInputChannel = channel
+ }
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: viewModel.passwordProtectedChannels.contains(channel) ? "lock.fill" : "lock")
+ .font(.system(size: 10))
+ }
+ .foregroundColor(viewModel.passwordProtectedChannels.contains(channel) ? backgroundColor : secondaryTextColor)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : Color.clear)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ // Leave button
+ Button(action: {
+ viewModel.leaveChannel(channel)
+ }) {
+ Text("leave channel")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(secondaryTextColor)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
private var sidebarView: some View {
HStack(spacing: 0) {
// Grey vertical bar for visual continuity
@@ -715,7 +834,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
- Text("connected")
+ Text("YOUR NETWORK")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
@@ -729,111 +848,10 @@ struct ContentView: View {
// Rooms and People list
ScrollView {
VStack(alignment: .leading, spacing: 12) {
- // Joined Rooms section
- if !viewModel.joinedRooms.isEmpty {
- VStack(alignment: .leading, spacing: 6) {
- Text("ROOMS")
- .font(.system(size: 11, weight: .semibold, design: .monospaced))
- .foregroundColor(secondaryTextColor)
- .padding(.horizontal, 12)
-
- ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
- Button(action: {
- // Check if room needs password and we don't have it
- if viewModel.passwordProtectedRooms.contains(room) && viewModel.roomKeys[room] == nil {
- // Need password
- viewModel.passwordPromptRoom = room
- viewModel.showPasswordPrompt = true
- } else {
- // Can enter room
- viewModel.switchToRoom(room)
- withAnimation(.spring()) {
- showSidebar = false
- }
- }
- }) {
- HStack {
- // Lock icon for password protected rooms
- if viewModel.passwordProtectedRooms.contains(room) {
- Image(systemName: "lock.fill")
- .font(.system(size: 10))
- .foregroundColor(secondaryTextColor)
- }
-
- Text(room)
- .font(.system(size: 14, design: .monospaced))
- .foregroundColor(viewModel.currentRoom == room ? Color.blue : textColor)
-
- Spacer()
-
- // Unread count
- if let unreadCount = viewModel.unreadRoomMessages[room], unreadCount > 0 {
- Text("\(unreadCount)")
- .font(.system(size: 10, weight: .bold, design: .monospaced))
- .foregroundColor(backgroundColor)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(Color.orange)
- .clipShape(Capsule())
- }
-
- // Room controls
- if viewModel.currentRoom == room {
- HStack(spacing: 4) {
- // Password button for room creator only
- if viewModel.roomCreators[room] == viewModel.meshService.myPeerID {
- Button(action: {
- // Toggle password protection
- if viewModel.passwordProtectedRooms.contains(room) {
- viewModel.removeRoomPassword(for: room)
- } else {
- // Show password input
- showPasswordInput = true
- passwordInputRoom = room
- }
- }) {
- HStack(spacing: 2) {
- Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
- .font(.system(size: 10))
- }
- .foregroundColor(viewModel.passwordProtectedRooms.contains(room) ? backgroundColor : secondaryTextColor)
- .padding(.horizontal, 8)
- .padding(.vertical, 2)
- .background(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : Color.clear)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .stroke(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- }
-
- // Leave button
- Button(action: {
- viewModel.leaveRoom(room)
- }) {
- Text("leave room")
- .font(.system(size: 10, design: .monospaced))
- .foregroundColor(secondaryTextColor)
- .padding(.horizontal, 8)
- .padding(.vertical, 2)
- .overlay(
- RoundedRectangle(cornerRadius: 4)
- .stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- }
- }
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 4)
- .background(viewModel.currentRoom == room ? backgroundColor.opacity(0.5) : Color.clear)
- }
- .buttonStyle(.plain)
- }
- }
-
+ // Channels section
+ channelsSection
+
+ if !viewModel.joinedChannels.isEmpty {
Divider()
.padding(.vertical, 4)
}
@@ -841,16 +859,20 @@ struct ContentView: View {
// People section
VStack(alignment: .leading, spacing: 8) {
// Show appropriate header based on context
- if let currentRoom = viewModel.currentRoom {
- Text("IN \(currentRoom.uppercased())")
+ if let currentChannel = viewModel.currentChannel {
+ Text("IN \(currentChannel.uppercased())")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal, 12)
} else if !viewModel.connectedPeers.isEmpty {
- Text("PEOPLE")
- .font(.system(size: 11, weight: .semibold, design: .monospaced))
- .foregroundColor(secondaryTextColor)
- .padding(.horizontal, 12)
+ HStack(spacing: 4) {
+ Image(systemName: "person.2.fill")
+ .font(.system(size: 10))
+ Text("PEOPLE")
+ .font(.system(size: 11, weight: .bold, design: .monospaced))
+ }
+ .foregroundColor(secondaryTextColor)
+ .padding(.horizontal, 12)
}
if viewModel.connectedPeers.isEmpty {
@@ -858,10 +880,10 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
- } else if let currentRoom = viewModel.currentRoom,
- let roomMemberIDs = viewModel.roomMembers[currentRoom],
- roomMemberIDs.isEmpty {
- Text("No one in this room yet")
+ } else if let currentChannel = viewModel.currentChannel,
+ let channelMemberIDs = viewModel.channelMembers[currentChannel],
+ channelMemberIDs.isEmpty {
+ Text("No one in this channel yet")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
@@ -870,17 +892,17 @@ struct ContentView: View {
let peerRSSI = viewModel.meshService.getPeerRSSI()
let myPeerID = viewModel.meshService.myPeerID
- // Filter peers based on current room
+ // Filter peers based on current channel
let peersToShow: [String] = {
- if let currentRoom = viewModel.currentRoom,
- let roomMemberIDs = viewModel.roomMembers[currentRoom] {
- // Show only peers who have sent messages to this room (including self)
+ if let currentChannel = viewModel.currentChannel,
+ let channelMemberIDs = viewModel.channelMembers[currentChannel] {
+ // Show only peers who have sent messages to this channel (including self)
- // Start with room members who are also connected
- var memberPeers = viewModel.connectedPeers.filter { roomMemberIDs.contains($0) }
+ // Start with channel members who are also connected
+ var memberPeers = viewModel.connectedPeers.filter { channelMemberIDs.contains($0) }
- // Always include ourselves if we're a room member
- if roomMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
+ // Always include ourselves if we're a channel member
+ if channelMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
memberPeers.append(myPeerID)
}
@@ -1014,33 +1036,30 @@ struct MessageContentView: View {
}
allMatches.sort { $0.range.location < $1.range.location }
- // Build the text view with clickable hashtags
- return HStack(spacing: 0) {
- ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in
- if segment.type == "hashtag" {
- Button(action: {
- _ = viewModel.joinRoom(segment.text)
- }) {
- Text(segment.text)
- .font(.system(size: 14, weight: .semibold, design: .monospaced))
- .foregroundColor(Color.blue)
- .underline()
- .textSelection(.enabled)
- }
- .buttonStyle(.plain)
- } else if segment.type == "mention" {
- Text(segment.text)
- .font(.system(size: 14, weight: .semibold, design: .monospaced))
- .foregroundColor(Color.orange)
- .textSelection(.enabled)
- } else {
- Text(segment.text)
- .font(.system(size: 14, design: .monospaced))
- .fontWeight(isMentioned ? .bold : .regular)
- .textSelection(.enabled)
- }
+ // Build the text as a concatenated Text view for natural wrapping
+ let segments = buildTextSegments()
+ var result = Text("")
+
+ for segment in segments {
+ if segment.type == "hashtag" {
+ // Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
+ result = result + Text(segment.text)
+ .font(.system(size: 14, weight: .semibold, design: .monospaced))
+ .foregroundColor(Color.blue)
+ .underline()
+ } else if segment.type == "mention" {
+ result = result + Text(segment.text)
+ .font(.system(size: 14, weight: .semibold, design: .monospaced))
+ .foregroundColor(Color.orange)
+ } else {
+ result = result + Text(segment.text)
+ .font(.system(size: 14, design: .monospaced))
+ .fontWeight(isMentioned ? .bold : .regular)
}
}
+
+ return result
+ .textSelection(.enabled)
}
private func buildTextSegments() -> [(text: String, type: String)] {
diff --git a/bitchatTests/BitchatMessageTests.swift b/bitchatTests/BitchatMessageTests.swift
index 6afbcea4..c622e9ce 100644
--- a/bitchatTests/BitchatMessageTests.swift
+++ b/bitchatTests/BitchatMessageTests.swift
@@ -43,7 +43,7 @@ class BitchatMessageTests: XCTestCase {
}
func testRoomMessage() {
- let roomMessage = BitchatMessage(
+ let channelMessage = BitchatMessage(
sender: "alice",
content: "Hello #general",
timestamp: Date(),
@@ -53,21 +53,21 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
- room: "#general"
+ channel: "#general"
)
- guard let encoded = roomMessage.toBinaryPayload() else {
- XCTFail("Failed to encode room message")
+ guard let encoded = channelMessage.toBinaryPayload() else {
+ XCTFail("Failed to encode channel message")
return
}
guard let decoded = BitchatMessage.fromBinaryPayload(encoded) else {
- XCTFail("Failed to decode room message")
+ XCTFail("Failed to decode channel message")
return
}
- XCTAssertEqual(decoded.room, "#general")
- XCTAssertEqual(decoded.content, roomMessage.content)
+ XCTAssertEqual(decoded.channel, "#general")
+ XCTAssertEqual(decoded.content, channelMessage.content)
}
func testEncryptedRoomMessage() {
@@ -83,7 +83,7 @@ class BitchatMessageTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "bob456",
mentions: nil,
- room: "#secret",
+ channel: "#secret",
encryptedContent: encryptedData,
isEncrypted: true
)
@@ -100,7 +100,7 @@ class BitchatMessageTests: XCTestCase {
XCTAssertTrue(decoded.isEncrypted)
XCTAssertEqual(decoded.encryptedContent, encryptedData)
- XCTAssertEqual(decoded.room, "#secret")
+ XCTAssertEqual(decoded.channel, "#secret")
XCTAssertEqual(decoded.content, "") // Content should be empty for encrypted messages
}
diff --git a/bitchatTests/PasswordProtectedRoomTests.swift b/bitchatTests/PasswordProtectedChannelTests.swift
similarity index 52%
rename from bitchatTests/PasswordProtectedRoomTests.swift
rename to bitchatTests/PasswordProtectedChannelTests.swift
index 7ef2674c..60fa3368 100644
--- a/bitchatTests/PasswordProtectedRoomTests.swift
+++ b/bitchatTests/PasswordProtectedChannelTests.swift
@@ -1,5 +1,5 @@
//
-// PasswordProtectedRoomTests.swift
+// PasswordProtectedChannelTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
@@ -11,7 +11,7 @@ import CryptoKit
import CommonCrypto
@testable import bitchat
-class PasswordProtectedRoomTests: XCTestCase {
+class PasswordProtectedChannelTests: XCTestCase {
var viewModel: ChatViewModel!
override func setUp() {
@@ -23,22 +23,22 @@ class PasswordProtectedRoomTests: XCTestCase {
viewModel = ChatViewModel()
// Ensure clean state
- viewModel.passwordProtectedRooms.removeAll()
- viewModel.roomCreators.removeAll()
- viewModel.roomPasswords.removeAll()
- viewModel.roomKeys.removeAll()
- viewModel.joinedRooms.removeAll()
- viewModel.roomMembers.removeAll()
- viewModel.roomMessages.removeAll()
+ viewModel.passwordProtectedChannels.removeAll()
+ viewModel.channelCreators.removeAll()
+ viewModel.channelPasswords.removeAll()
+ viewModel.channelKeys.removeAll()
+ viewModel.joinedChannels.removeAll()
+ viewModel.channelMembers.removeAll()
+ viewModel.channelMessages.removeAll()
}
private func clearAllUserDefaults() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "bitchat_nickname")
- defaults.removeObject(forKey: "bitchat_joined_rooms")
- defaults.removeObject(forKey: "bitchat_password_protected_rooms")
- defaults.removeObject(forKey: "bitchat_room_creators")
- defaults.removeObject(forKey: "bitchat_room_passwords")
+ defaults.removeObject(forKey: "bitchat_joined_channels")
+ defaults.removeObject(forKey: "bitchat_password_protected_channels")
+ defaults.removeObject(forKey: "bitchat_channel_creators")
+ defaults.removeObject(forKey: "bitchat_channel_passwords")
defaults.removeObject(forKey: "bitchat_favorite_peers")
defaults.synchronize()
}
@@ -53,100 +53,100 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Key Derivation Tests
func testPasswordKeyDerivation() {
- // Same password and room should always produce same key
+ // Same password and channel should always produce same key
let password = "secretPassword123"
- let roomName = "#testroom"
+ let channelName = "#testchannel"
- let key1 = deriveRoomKey(from: password, roomName: roomName)
- let key2 = deriveRoomKey(from: password, roomName: roomName)
+ let key1 = deriveChannelKey(from: password, channelName: channelName)
+ let key2 = deriveChannelKey(from: password, channelName: channelName)
// Keys should be identical
- XCTAssertEqual(key1, key2, "Same password and room should produce same key")
+ XCTAssertEqual(key1, key2, "Same password and channel should produce same key")
}
func testDifferentPasswordsProduceDifferentKeys() {
- let roomName = "#testroom"
+ let channelName = "#testchannel"
let password1 = "password123"
let password2 = "different456"
- let key1 = deriveRoomKey(from: password1, roomName: roomName)
- let key2 = deriveRoomKey(from: password2, roomName: roomName)
+ let key1 = deriveChannelKey(from: password1, channelName: channelName)
+ let key2 = deriveChannelKey(from: password2, channelName: channelName)
XCTAssertNotEqual(key1, key2, "Different passwords should produce different keys")
}
- func testDifferentRoomsProduceDifferentKeys() {
+ func testDifferentChannelsProduceDifferentKeys() {
let password = "samePassword"
- let room1 = "#room1"
- let room2 = "#room2"
+ let channel1 = "#channel1"
+ let channel2 = "#channel2"
- let key1 = deriveRoomKey(from: password, roomName: room1)
- let key2 = deriveRoomKey(from: password, roomName: room2)
+ let key1 = deriveChannelKey(from: password, channelName: channel1)
+ let key2 = deriveChannelKey(from: password, channelName: channel2)
- XCTAssertNotEqual(key1, key2, "Same password in different rooms should produce different keys")
+ XCTAssertNotEqual(key1, key2, "Same password in different channels should produce different keys")
}
- // MARK: - Room Creation and Joining Tests
+ // MARK: - Channel Creation and Joining Tests
- func testJoinUnprotectedRoom() {
- let roomName = "#public"
+ func testJoinUnprotectedChannel() {
+ let channelName = "#public"
- let success = viewModel.joinRoom(roomName)
+ let success = viewModel.joinChannel(channelName)
- XCTAssertTrue(success, "Should be able to join unprotected room")
- XCTAssertTrue(viewModel.joinedRooms.contains(roomName))
- XCTAssertEqual(viewModel.currentRoom, roomName)
- XCTAssertTrue(viewModel.roomMembers[roomName]?.contains(viewModel.meshService.myPeerID) ?? false)
+ XCTAssertTrue(success, "Should be able to join unprotected channel")
+ XCTAssertTrue(viewModel.joinedChannels.contains(channelName))
+ XCTAssertEqual(viewModel.currentChannel, channelName)
+ XCTAssertTrue(viewModel.channelMembers[channelName]?.contains(viewModel.meshService.myPeerID) ?? false)
}
- func testCreatePasswordProtectedRoom() {
- let roomName = "#private"
+ func testCreatePasswordProtectedChannel() {
+ let channelName = "#private"
let password = "secret123"
- // Join room first
- let joinSuccess = viewModel.joinRoom(roomName)
+ // Join channel first
+ let joinSuccess = viewModel.joinChannel(channelName)
XCTAssertTrue(joinSuccess)
// Set password
- viewModel.setRoomPassword(password, for: roomName)
+ viewModel.setChannelPassword(password, for: channelName)
- XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
- XCTAssertNotNil(viewModel.roomKeys[roomName])
- XCTAssertEqual(viewModel.roomPasswords[roomName], password)
- XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
+ XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
+ XCTAssertNotNil(viewModel.channelKeys[channelName])
+ XCTAssertEqual(viewModel.channelPasswords[channelName], password)
+ XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
}
- func testJoinPasswordProtectedEmptyRoom() {
- let roomName = "#protected"
+ func testJoinPasswordProtectedEmptyChannel() {
+ let channelName = "#protected"
let password = "test123"
- // Simulate room being marked as password protected
- viewModel.passwordProtectedRooms.insert(roomName)
+ // Simulate channel being marked as password protected
+ viewModel.passwordProtectedChannels.insert(channelName)
- // Try to join with password - should be accepted tentatively for empty room
- let success = viewModel.joinRoom(roomName, password: password)
+ // Try to join with password - should be accepted tentatively for empty channel
+ let success = viewModel.joinChannel(channelName, password: password)
- XCTAssertTrue(success, "Should accept tentative access to empty password-protected room")
- XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key tentatively")
- XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Should store password tentatively")
+ XCTAssertTrue(success, "Should accept tentative access to empty password-protected channel")
+ XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key tentatively")
+ XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Should store password tentatively")
// Should have a system message explaining tentative access
let hasSystemMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("waiting for encrypted messages to verify password") }
XCTAssertTrue(hasSystemMessage, "Should add system message explaining tentative access")
}
- func testJoinPasswordProtectedRoomWithMessages() {
- let roomName = "#secure"
+ func testJoinPasswordProtectedChannelWithMessages() {
+ let channelName = "#secure"
let correctPassword = "correct123"
let wrongPassword = "wrong456"
let testMessage = "Test encrypted message"
- // First, create the room and set password as creator
- let _ = viewModel.joinRoom(roomName)
- viewModel.setRoomPassword(correctPassword, for: roomName)
+ // First, create the channel and set password as creator
+ let _ = viewModel.joinChannel(channelName)
+ viewModel.setChannelPassword(correctPassword, for: channelName)
- // Simulate an encrypted message in the room
- let key = viewModel.roomKeys[roomName]!
+ // Simulate an encrypted message in the channel
+ let key = viewModel.channelKeys[channelName]!
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
return
@@ -166,26 +166,26 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
- room: roomName,
+ channel: channelName,
encryptedContent: encryptedData,
isEncrypted: true
)
- // Add to room messages
- viewModel.roomMessages[roomName] = [encryptedMsg]
+ // Add to channel messages
+ viewModel.channelMessages[channelName] = [encryptedMsg]
// Clear keys to simulate another user
- viewModel.roomKeys.removeValue(forKey: roomName)
- viewModel.roomPasswords.removeValue(forKey: roomName)
+ viewModel.channelKeys.removeValue(forKey: channelName)
+ viewModel.channelPasswords.removeValue(forKey: channelName)
// Try to join with wrong password
- let wrongSuccess = viewModel.joinRoom(roomName, password: wrongPassword)
+ let wrongSuccess = viewModel.joinChannel(channelName, password: wrongPassword)
XCTAssertFalse(wrongSuccess, "Should reject wrong password")
// Try to join with correct password
- let correctSuccess = viewModel.joinRoom(roomName, password: correctPassword)
+ let correctSuccess = viewModel.joinChannel(channelName, password: correctPassword)
XCTAssertTrue(correctSuccess, "Should accept correct password")
- XCTAssertNotNil(viewModel.roomKeys[roomName], "Should store key for correct password")
+ XCTAssertNotNil(viewModel.channelKeys[channelName], "Should store key for correct password")
} catch {
XCTFail("Encryption failed: \(error)")
@@ -194,13 +194,13 @@ class PasswordProtectedRoomTests: XCTestCase {
// MARK: - Password Verification Tests
- func testEncryptDecryptRoomMessage() {
- let roomName = "#crypto"
+ func testEncryptDecryptChannelMessage() {
+ let channelName = "#crypto"
let password = "cryptoKey"
let testMessage = "This is a secret message"
// Derive key
- let key = deriveRoomKey(from: password, roomName: roomName)
+ let key = deriveChannelKey(from: password, channelName: channelName)
// Encrypt
guard let messageData = testMessage.data(using: .utf8) else {
@@ -213,8 +213,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined!
// Store key and decrypt
- viewModel.roomKeys[roomName] = key
- let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName)
+ viewModel.channelKeys[channelName] = key
+ let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName)
XCTAssertEqual(decrypted, testMessage, "Decrypted message should match original")
} catch {
@@ -223,13 +223,13 @@ class PasswordProtectedRoomTests: XCTestCase {
}
func testWrongPasswordFailsDecryption() {
- let roomName = "#secure"
+ let channelName = "#secure"
let correctPassword = "correct"
let wrongPassword = "wrong"
let testMessage = "Secret content"
// Encrypt with correct password
- let correctKey = deriveRoomKey(from: correctPassword, roomName: roomName)
+ let correctKey = deriveChannelKey(from: correctPassword, channelName: channelName)
guard let messageData = testMessage.data(using: .utf8) else {
XCTFail("Failed to convert message to data")
@@ -241,8 +241,8 @@ class PasswordProtectedRoomTests: XCTestCase {
let encryptedData = sealedBox.combined!
// Try to decrypt with wrong password
- let wrongKey = deriveRoomKey(from: wrongPassword, roomName: roomName)
- let decrypted = viewModel.decryptRoomMessage(encryptedData, room: roomName, testKey: wrongKey)
+ let wrongKey = deriveChannelKey(from: wrongPassword, channelName: channelName)
+ let decrypted = viewModel.decryptChannelMessage(encryptedData, channel: channelName, testKey: wrongKey)
XCTAssertNil(decrypted, "Wrong password should fail to decrypt")
} catch {
@@ -250,52 +250,52 @@ class PasswordProtectedRoomTests: XCTestCase {
}
}
- // MARK: - Room Creator Tests
+ // MARK: - Channel Creator Tests
func testOnlyCreatorCanSetPassword() {
- let roomName = "#owned"
+ let channelName = "#owned"
let password = "ownerOnly"
- // Join room (becomes creator)
- let _ = viewModel.joinRoom(roomName)
+ // Join channel (becomes creator)
+ let _ = viewModel.joinChannel(channelName)
// Set password as creator
- viewModel.setRoomPassword(password, for: roomName)
- XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
+ viewModel.setChannelPassword(password, for: channelName)
+ XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Simulate another user trying to set password
- viewModel.roomCreators[roomName] = "otherUser123"
- viewModel.setRoomPassword("hackerPassword", for: roomName)
+ viewModel.channelCreators[channelName] = "otherUser123"
+ viewModel.setChannelPassword("hackerPassword", for: channelName)
// Password should not change
- XCTAssertEqual(viewModel.roomPasswords[roomName], password, "Non-creator should not be able to change password")
+ XCTAssertEqual(viewModel.channelPasswords[channelName], password, "Non-creator should not be able to change password")
}
func testCreatorCanRemovePassword() {
- let roomName = "#changeable"
+ let channelName = "#changeable"
let password = "temporary"
- // Create protected room
- let _ = viewModel.joinRoom(roomName)
- viewModel.setRoomPassword(password, for: roomName)
+ // Create protected channel
+ let _ = viewModel.joinChannel(channelName)
+ viewModel.setChannelPassword(password, for: channelName)
- XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
+ XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Remove password
- viewModel.removeRoomPassword(for: roomName)
+ viewModel.removeChannelPassword(for: channelName)
- XCTAssertFalse(viewModel.passwordProtectedRooms.contains(roomName))
- XCTAssertNil(viewModel.roomKeys[roomName])
- XCTAssertNil(viewModel.roomPasswords[roomName])
+ XCTAssertFalse(viewModel.passwordProtectedChannels.contains(channelName))
+ XCTAssertNil(viewModel.channelKeys[channelName])
+ XCTAssertNil(viewModel.channelPasswords[channelName])
}
// MARK: - Message Handling Tests
func testReceiveEncryptedMessageWithoutKey() {
- let roomName = "#encrypted"
+ let channelName = "#encrypted"
- // Join room without password
- let _ = viewModel.joinRoom(roomName)
+ // Join channel without password
+ let _ = viewModel.joinChannel(channelName)
// Simulate receiving encrypted message
let encryptedMessage = BitchatMessage(
@@ -308,109 +308,109 @@ class PasswordProtectedRoomTests: XCTestCase {
recipientNickname: nil,
senderPeerID: "alice123",
mentions: nil,
- room: roomName,
+ channel: channelName,
encryptedContent: Data([1, 2, 3, 4]), // dummy encrypted data
isEncrypted: true
)
viewModel.didReceiveMessage(encryptedMessage)
- // Should mark room as password protected
- XCTAssertTrue(viewModel.passwordProtectedRooms.contains(roomName))
+ // Should mark channel as password protected
+ XCTAssertTrue(viewModel.passwordProtectedChannels.contains(channelName))
// Should add system message
- let roomMessages = viewModel.roomMessages[roomName] ?? []
- let hasSystemMessage = roomMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
+ let channelMessages = viewModel.channelMessages[channelName] ?? []
+ let hasSystemMessage = channelMessages.contains { $0.sender == "system" && $0.content.contains("password protected") }
XCTAssertTrue(hasSystemMessage, "Should add system message about password protection")
}
// MARK: - Command Tests
func testJoinCommand() {
- let input = "/join #testroom"
+ let input = "/join #testchannel"
viewModel.sendMessage(input)
- XCTAssertTrue(viewModel.joinedRooms.contains("#testroom"))
- XCTAssertEqual(viewModel.currentRoom, "#testroom")
+ XCTAssertTrue(viewModel.joinedChannels.contains("#testchannel"))
+ XCTAssertEqual(viewModel.currentChannel, "#testchannel")
}
func testJoinCommandAlias() {
let input = "/j #quick"
viewModel.sendMessage(input)
- XCTAssertTrue(viewModel.joinedRooms.contains("#quick"))
- XCTAssertEqual(viewModel.currentRoom, "#quick")
+ XCTAssertTrue(viewModel.joinedChannels.contains("#quick"))
+ XCTAssertEqual(viewModel.currentChannel, "#quick")
}
- func testInvalidRoomName() {
- let input = "/j #invalid-room!"
+ func testInvalidChannelName() {
+ let input = "/j #invalid-channel!"
viewModel.sendMessage(input)
- XCTAssertFalse(viewModel.joinedRooms.contains("#invalid-room!"))
+ XCTAssertFalse(viewModel.joinedChannels.contains("#invalid-channel!"))
// Should have system message about invalid name
- let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("Invalid room name") }
+ let hasErrorMessage = viewModel.messages.contains { $0.sender == "system" && $0.content.contains("Invalid channel name") }
XCTAssertTrue(hasErrorMessage)
}
// MARK: - Key Commitment Tests
func testKeyCommitmentVerification() {
- let roomName = "#commitment"
+ let channelName = "#commitment"
let password = "testpass123"
// Join and set password
- let _ = viewModel.joinRoom(roomName)
- viewModel.setRoomPassword(password, for: roomName)
+ let _ = viewModel.joinChannel(channelName)
+ viewModel.setChannelPassword(password, for: channelName)
// Verify key commitment was stored
- XCTAssertNotNil(viewModel.roomKeyCommitments[roomName], "Should store key commitment")
+ XCTAssertNotNil(viewModel.channelKeyCommitments[channelName], "Should store key commitment")
// Simulate another user with the stored commitment
- let commitment = viewModel.roomKeyCommitments[roomName]!
- viewModel.roomKeys.removeValue(forKey: roomName)
- viewModel.roomPasswords.removeValue(forKey: roomName)
+ let commitment = viewModel.channelKeyCommitments[channelName]!
+ viewModel.channelKeys.removeValue(forKey: channelName)
+ viewModel.channelPasswords.removeValue(forKey: channelName)
// Manually set the commitment as if received from network
- viewModel.roomKeyCommitments[roomName] = commitment
+ viewModel.channelKeyCommitments[channelName] = commitment
// Try with wrong password - should fail immediately
- let wrongSuccess = viewModel.joinRoom(roomName, password: "wrongpass")
+ let wrongSuccess = viewModel.joinChannel(channelName, password: "wrongpass")
XCTAssertFalse(wrongSuccess, "Should reject wrong password via commitment check")
// Try with correct password - should succeed
- let correctSuccess = viewModel.joinRoom(roomName, password: password)
+ let correctSuccess = viewModel.joinChannel(channelName, password: password)
XCTAssertTrue(correctSuccess, "Should accept correct password via commitment check")
}
func testOwnershipTransfer() {
- let roomName = "#transfertest"
+ let channelName = "#transfertest"
let password = "ownerpass"
- // Create room and set password
- let _ = viewModel.joinRoom(roomName)
- viewModel.setRoomPassword(password, for: roomName)
+ // Create channel and set password
+ let _ = viewModel.joinChannel(channelName)
+ viewModel.setChannelPassword(password, for: channelName)
// Verify creator is set
- XCTAssertEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
+ XCTAssertEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
// Simulate transfer (in real app would use /transfer command)
let newOwnerID = "newowner123"
- viewModel.roomCreators[roomName] = newOwnerID
+ viewModel.channelCreators[channelName] = newOwnerID
// Verify ownership changed
- XCTAssertEqual(viewModel.roomCreators[roomName], newOwnerID)
- XCTAssertNotEqual(viewModel.roomCreators[roomName], viewModel.meshService.myPeerID)
+ XCTAssertEqual(viewModel.channelCreators[channelName], newOwnerID)
+ XCTAssertNotEqual(viewModel.channelCreators[channelName], viewModel.meshService.myPeerID)
}
}
// MARK: - Helper Extensions for Testing
-extension PasswordProtectedRoomTests {
- // Helper method to derive room key for testing
+extension PasswordProtectedChannelTests {
+ // Helper method to derive channel key for testing
// This duplicates the logic from ChatViewModel for testing purposes
- func deriveRoomKey(from password: String, roomName: String) -> SymmetricKey {
- let salt = roomName.data(using: .utf8)!
+ func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
+ let salt = channelName.data(using: .utf8)!
let iterations = 100000
let keyLength = 32