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