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