mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 14:05:22 +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:
@@ -227,7 +227,7 @@ extension BitchatMessage {
|
||||
var data = Data()
|
||||
|
||||
// Message format:
|
||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasRoom, bit 7: isEncrypted)
|
||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasChannel, bit 7: isEncrypted)
|
||||
// - Timestamp: 8 bytes (seconds since epoch)
|
||||
// - ID length: 1 byte
|
||||
// - ID: variable
|
||||
@@ -240,7 +240,7 @@ extension BitchatMessage {
|
||||
// - Recipient nickname length + data
|
||||
// - Sender peer ID length + data
|
||||
// - Mentions array
|
||||
// - Room hashtag
|
||||
// - Channel hashtag
|
||||
|
||||
var flags: UInt8 = 0
|
||||
if isRelay { flags |= 0x01 }
|
||||
@@ -249,7 +249,7 @@ extension BitchatMessage {
|
||||
if recipientNickname != nil { flags |= 0x08 }
|
||||
if senderPeerID != nil { flags |= 0x10 }
|
||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||
if room != nil { flags |= 0x40 }
|
||||
if channel != nil { flags |= 0x40 }
|
||||
if isEncrypted { flags |= 0x80 }
|
||||
|
||||
data.append(flags)
|
||||
@@ -323,10 +323,10 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Room hashtag
|
||||
if let room = room, let roomData = room.data(using: .utf8) {
|
||||
data.append(UInt8(min(roomData.count, 255)))
|
||||
data.append(roomData.prefix(255))
|
||||
// Channel hashtag
|
||||
if let channel = channel, let channelData = channel.data(using: .utf8) {
|
||||
data.append(UInt8(min(channelData.count, 255)))
|
||||
data.append(channelData.prefix(255))
|
||||
}
|
||||
|
||||
return data
|
||||
@@ -354,7 +354,7 @@ extension BitchatMessage {
|
||||
let hasRecipientNickname = (flags & 0x08) != 0
|
||||
let hasSenderPeerID = (flags & 0x10) != 0
|
||||
let hasMentions = (flags & 0x20) != 0
|
||||
let hasRoom = (flags & 0x40) != 0
|
||||
let hasChannel = (flags & 0x40) != 0
|
||||
let isEncrypted = (flags & 0x80) != 0
|
||||
|
||||
// Timestamp
|
||||
@@ -465,12 +465,12 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Room
|
||||
var room: String? = nil
|
||||
if hasRoom && offset < dataCopy.count {
|
||||
// Channel
|
||||
var channel: String? = nil
|
||||
if hasChannel && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
room = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
channel = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
@@ -486,7 +486,7 @@ extension BitchatMessage {
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions,
|
||||
room: room,
|
||||
channel: channel,
|
||||
encryptedContent: encryptedContent,
|
||||
isEncrypted: isEncrypted
|
||||
)
|
||||
|
||||
@@ -72,8 +72,8 @@ enum MessageType: UInt8 {
|
||||
case fragmentStart = 0x05
|
||||
case fragmentContinue = 0x06
|
||||
case fragmentEnd = 0x07
|
||||
case roomAnnounce = 0x08 // Announce password-protected room status
|
||||
case roomRetention = 0x09 // Announce room retention status
|
||||
case channelAnnounce = 0x08 // Announce password-protected channel status
|
||||
case channelRetention = 0x09 // Announce channel retention status
|
||||
case deliveryAck = 0x0A // Acknowledge message received
|
||||
case deliveryStatusRequest = 0x0B // Request delivery status update
|
||||
case readReceipt = 0x0C // Message has been read/viewed
|
||||
@@ -220,12 +220,12 @@ struct BitchatMessage: Codable, Equatable {
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
let room: String? // Room hashtag (e.g., "#general")
|
||||
let channel: String? // Channel hashtag (e.g., "#general")
|
||||
let encryptedContent: Data? // For password-protected rooms
|
||||
let isEncrypted: Bool // Flag to indicate if content is encrypted
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) {
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, channel: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -236,7 +236,7 @@ struct BitchatMessage: Codable, Equatable {
|
||||
self.recipientNickname = recipientNickname
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
self.room = room
|
||||
self.channel = channel
|
||||
self.encryptedContent = encryptedContent
|
||||
self.isEncrypted = isEncrypted
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||
@@ -248,10 +248,10 @@ protocol BitchatDelegate: AnyObject {
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
func didReceiveRoomLeave(_ room: String, from peerID: String)
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
|
||||
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?)
|
||||
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String?
|
||||
func didReceiveChannelLeave(_ channel: String, from peerID: String)
|
||||
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?)
|
||||
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?)
|
||||
func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String?
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
@@ -268,19 +268,19 @@ extension BitchatDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
func didReceiveRoomLeave(_ room: String, from peerID: String) {
|
||||
func didReceiveChannelLeave(_ channel: String, from peerID: String) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePasswordProtectedRoomAnnouncement(_ room: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
|
||||
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveRoomRetentionAnnouncement(_ room: String, enabled: Bool, creatorID: String?) {
|
||||
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func decryptRoomMessage(_ encryptedContent: Data, room: String) -> String? {
|
||||
func decryptChannelMessage(_ encryptedContent: Data, channel: String) -> String? {
|
||||
// Default returns nil (unable to decrypt)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ class BluetoothMeshService: NSObject {
|
||||
self.characteristic = characteristic
|
||||
}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil) {
|
||||
func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -506,7 +506,7 @@ class BluetoothMeshService: NSObject {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: self.myPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions,
|
||||
room: room
|
||||
channel: channel
|
||||
)
|
||||
|
||||
if let messageData = message.toBinaryPayload() {
|
||||
@@ -653,17 +653,17 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func sendRoomLeaveNotification(_ room: String) {
|
||||
func sendChannelLeaveNotification(_ channel: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Create a leave packet with room hashtag as payload
|
||||
// Create a leave packet with channel hashtag as payload
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: Data(self.myPeerID.utf8),
|
||||
recipientID: SpecialRecipients.broadcast, // Broadcast to all
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(room.utf8), // Room hashtag as payload
|
||||
payload: Data(channel.utf8), // Channel hashtag as payload
|
||||
signature: nil,
|
||||
ttl: 3 // Short TTL for leave notifications
|
||||
)
|
||||
@@ -738,53 +738,53 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
|
||||
func announcePasswordProtectedChannel(_ channel: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Payload format: room|isProtected|creatorID|keyCommitment
|
||||
// Payload format: channel|isProtected|creatorID|keyCommitment
|
||||
let protectedFlag = isProtected ? "1" : "0"
|
||||
let creator = creatorID ?? self.myPeerID
|
||||
let commitment = keyCommitment ?? ""
|
||||
let payload = "\(room)|\(protectedFlag)|\(creator)|\(commitment)"
|
||||
let payload = "\(channel)|\(protectedFlag)|\(creator)|\(commitment)"
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.roomAnnounce.rawValue,
|
||||
type: MessageType.channelAnnounce.rawValue,
|
||||
senderID: Data(self.myPeerID.utf8),
|
||||
recipientID: SpecialRecipients.broadcast,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(payload.utf8),
|
||||
signature: nil,
|
||||
ttl: 5 // Allow wider propagation for room announcements
|
||||
ttl: 5 // Allow wider propagation for channel announcements
|
||||
)
|
||||
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendRoomRetentionAnnouncement(_ room: String, enabled: Bool) {
|
||||
func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Payload format: room|enabled|creatorID
|
||||
// Payload format: channel|enabled|creatorID
|
||||
let enabledFlag = enabled ? "1" : "0"
|
||||
let payload = "\(room)|\(enabledFlag)|\(self.myPeerID)"
|
||||
let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.roomRetention.rawValue,
|
||||
type: MessageType.channelRetention.rawValue,
|
||||
senderID: Data(self.myPeerID.utf8),
|
||||
recipientID: SpecialRecipients.broadcast,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(payload.utf8),
|
||||
signature: nil,
|
||||
ttl: 5 // Allow wider propagation for room announcements
|
||||
ttl: 5 // Allow wider propagation for channel announcements
|
||||
)
|
||||
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func sendEncryptedRoomMessage(_ content: String, mentions: [String], room: String, roomKey: SymmetricKey) {
|
||||
func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -797,7 +797,7 @@ class BluetoothMeshService: NSObject {
|
||||
// Debug logging removed
|
||||
|
||||
do {
|
||||
let sealedBox = try AES.GCM.seal(contentData, using: roomKey)
|
||||
let sealedBox = try AES.GCM.seal(contentData, using: channelKey)
|
||||
let encryptedData = sealedBox.combined!
|
||||
|
||||
// Create message with encrypted content
|
||||
@@ -811,7 +811,7 @@ class BluetoothMeshService: NSObject {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: self.myPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions,
|
||||
room: room,
|
||||
channel: channel,
|
||||
encryptedContent: encryptedData,
|
||||
isEncrypted: true
|
||||
)
|
||||
@@ -1244,7 +1244,7 @@ class BluetoothMeshService: NSObject {
|
||||
MessageRetryService.shared.addMessageForRetry(
|
||||
content: message.content,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: nil,
|
||||
recipientNickname: message.recipientNickname
|
||||
@@ -1298,24 +1298,24 @@ class BluetoothMeshService: NSObject {
|
||||
if sentToPeripherals == 0 && sentToCentrals == 0 {
|
||||
if packet.type == MessageType.message.rawValue,
|
||||
let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||
// For encrypted room messages, we need to preserve the room key
|
||||
var roomKeyData: Data? = nil
|
||||
if let room = message.room, message.isEncrypted {
|
||||
// This is an encrypted room message
|
||||
// For encrypted channel messages, we need to preserve the channel key
|
||||
var channelKeyData: Data? = nil
|
||||
if let channel = message.channel, message.isEncrypted {
|
||||
// This is an encrypted channel message
|
||||
if let viewModel = delegate as? ChatViewModel,
|
||||
let roomKey = viewModel.roomKeys[room] {
|
||||
roomKeyData = roomKey.withUnsafeBytes { Data($0) }
|
||||
let channelKey = viewModel.channelKeys[channel] {
|
||||
channelKeyData = channelKey.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
MessageRetryService.shared.addMessageForRetry(
|
||||
content: message.content,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: nil,
|
||||
recipientNickname: message.recipientNickname,
|
||||
roomKey: roomKeyData
|
||||
channelKey: channelKeyData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1430,11 +1430,11 @@ class BluetoothMeshService: NSObject {
|
||||
peerNicknames[senderID] = message.sender
|
||||
peerNicknamesLock.unlock()
|
||||
|
||||
// Handle encrypted room messages
|
||||
// Handle encrypted channel messages
|
||||
var finalContent = message.content
|
||||
if message.isEncrypted, let room = message.room, let encryptedData = message.encryptedContent {
|
||||
if message.isEncrypted, let channel = message.channel, let encryptedData = message.encryptedContent {
|
||||
// Try to decrypt the content
|
||||
if let decryptedContent = self.delegate?.decryptRoomMessage(encryptedData, room: room) {
|
||||
if let decryptedContent = self.delegate?.decryptChannelMessage(encryptedData, channel: channel) {
|
||||
finalContent = decryptedContent
|
||||
} else {
|
||||
// Unable to decrypt - show placeholder
|
||||
@@ -1453,7 +1453,7 @@ class BluetoothMeshService: NSObject {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderID,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
encryptedContent: message.encryptedContent,
|
||||
isEncrypted: message.isEncrypted
|
||||
)
|
||||
@@ -1467,10 +1467,10 @@ class BluetoothMeshService: NSObject {
|
||||
self.delegate?.didReceiveMessage(messageWithPeerID)
|
||||
}
|
||||
|
||||
// Generate and send ACK for room messages if we're mentioned or it's a small room
|
||||
// Generate and send ACK for channel messages if we're mentioned or it's a small channel
|
||||
let viewModel = self.delegate as? ChatViewModel
|
||||
let myNickname = viewModel?.nickname ?? self.myPeerID
|
||||
if let _ = message.room,
|
||||
if let _ = message.channel,
|
||||
let mentions = message.mentions,
|
||||
(mentions.contains(myNickname) || self.activePeers.count < 10) {
|
||||
if let ack = DeliveryTracker.shared.generateAck(
|
||||
@@ -1575,7 +1575,7 @@ class BluetoothMeshService: NSObject {
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: senderID,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
deliveryStatus: nil // Will be set to .delivered in ChatViewModel
|
||||
)
|
||||
|
||||
@@ -1867,13 +1867,13 @@ class BluetoothMeshService: NSObject {
|
||||
|
||||
case .leave:
|
||||
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
|
||||
// Check if payload contains a room hashtag
|
||||
if let room = String(data: packet.payload, encoding: .utf8),
|
||||
room.hasPrefix("#") {
|
||||
// Room leave notification
|
||||
// Check if payload contains a channel hashtag
|
||||
if let channel = String(data: packet.payload, encoding: .utf8),
|
||||
channel.hasPrefix("#") {
|
||||
// Channel leave notification
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveRoomLeave(room, from: senderID)
|
||||
self.delegate?.didReceiveChannelLeave(channel, from: senderID)
|
||||
}
|
||||
|
||||
// Relay if TTL > 0
|
||||
@@ -1924,19 +1924,19 @@ class BluetoothMeshService: NSObject {
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
|
||||
case .roomAnnounce:
|
||||
case .channelAnnounce:
|
||||
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
|
||||
// Parse payload: room|isProtected|creatorID|keyCommitment
|
||||
// Parse payload: channel|isProtected|creatorID|keyCommitment
|
||||
let components = payloadStr.split(separator: "|").map(String.init)
|
||||
if components.count >= 3 {
|
||||
let room = components[0]
|
||||
let channel = components[0]
|
||||
let isProtected = components[1] == "1"
|
||||
let creatorID = components[2]
|
||||
let keyCommitment = components.count >= 4 ? components[3] : nil
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceivePasswordProtectedRoomAnnouncement(room, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
|
||||
self.delegate?.didReceivePasswordProtectedChannelAnnouncement(channel, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
|
||||
}
|
||||
|
||||
// Relay announcement
|
||||
@@ -1977,18 +1977,18 @@ class BluetoothMeshService: NSObject {
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
|
||||
case .roomRetention:
|
||||
case .channelRetention:
|
||||
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
|
||||
// Parse payload: room|enabled|creatorID
|
||||
// Parse payload: channel|enabled|creatorID
|
||||
let components = payloadStr.split(separator: "|").map(String.init)
|
||||
if components.count >= 3 {
|
||||
let room = components[0]
|
||||
let channel = components[0]
|
||||
let enabled = components[1] == "1"
|
||||
let creatorID = components[2]
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveRoomRetentionAnnouncement(room, enabled: enabled, creatorID: creatorID)
|
||||
self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
|
||||
}
|
||||
|
||||
// Relay announcement
|
||||
|
||||
@@ -41,19 +41,19 @@ class DeliveryTracker {
|
||||
let recipientID: String
|
||||
let recipientNickname: String
|
||||
let retryCount: Int
|
||||
let isRoomMessage: Bool
|
||||
let isChannelMessage: Bool
|
||||
let isFavorite: Bool
|
||||
var ackedBy: Set<String> = [] // For tracking partial room delivery
|
||||
let expectedRecipients: Int // For room messages
|
||||
var ackedBy: Set<String> = [] // For tracking partial channel delivery
|
||||
let expectedRecipients: Int // For channel messages
|
||||
var timeoutTimer: Timer?
|
||||
|
||||
var isTimedOut: Bool {
|
||||
let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30)
|
||||
let timeout: TimeInterval = isFavorite ? 300 : (isChannelMessage ? 60 : 30)
|
||||
return Date().timeIntervalSince(sentAt) > timeout
|
||||
}
|
||||
|
||||
var shouldRetry: Bool {
|
||||
return retryCount < 3 && isFavorite && !isRoomMessage
|
||||
return retryCount < 3 && isFavorite && !isChannelMessage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class DeliveryTracker {
|
||||
|
||||
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
|
||||
// Don't track broadcasts or certain message types
|
||||
guard message.isPrivate || message.room != nil else { return }
|
||||
guard message.isPrivate || message.channel != nil else { return }
|
||||
|
||||
|
||||
let delivery = PendingDelivery(
|
||||
@@ -78,7 +78,7 @@ class DeliveryTracker {
|
||||
recipientID: recipientID,
|
||||
recipientNickname: recipientNickname,
|
||||
retryCount: 0,
|
||||
isRoomMessage: message.room != nil,
|
||||
isChannelMessage: message.channel != nil,
|
||||
isFavorite: isFavorite,
|
||||
expectedRecipients: expectedRecipients,
|
||||
timeoutTimer: nil
|
||||
@@ -118,8 +118,8 @@ class DeliveryTracker {
|
||||
// Cancel timeout timer
|
||||
delivery.timeoutTimer?.invalidate()
|
||||
|
||||
if delivery.isRoomMessage {
|
||||
// Track partial delivery for room messages
|
||||
if delivery.isChannelMessage {
|
||||
// Track partial delivery for channel messages
|
||||
delivery.ackedBy.insert(ack.recipientID)
|
||||
pendingDeliveries[ack.originalMessageID] = delivery
|
||||
|
||||
@@ -146,7 +146,7 @@ class DeliveryTracker {
|
||||
guard message.senderPeerID != myPeerID else { return nil }
|
||||
|
||||
// Don't ACK broadcasts or system messages
|
||||
guard message.isPrivate || message.room != nil else { return nil }
|
||||
guard message.isPrivate || message.channel != nil else { return nil }
|
||||
|
||||
// Don't ACK if we've already sent an ACK for this message
|
||||
guard !sentAckIDs.contains(message.id) else { return nil }
|
||||
@@ -187,11 +187,11 @@ class DeliveryTracker {
|
||||
return
|
||||
}
|
||||
let isFavorite = delivery.isFavorite
|
||||
let isRoomMessage = delivery.isRoomMessage
|
||||
let isChannelMessage = delivery.isChannelMessage
|
||||
pendingLock.unlock()
|
||||
|
||||
let timeout = isFavorite ? favoriteTimeout :
|
||||
(isRoomMessage ? roomMessageTimeout : privateMessageTimeout)
|
||||
(isChannelMessage ? roomMessageTimeout : privateMessageTimeout)
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
|
||||
self?.handleTimeout(messageID: messageID)
|
||||
@@ -213,7 +213,7 @@ class DeliveryTracker {
|
||||
}
|
||||
|
||||
let shouldRetry = delivery.shouldRetry
|
||||
let isRoomMessage = delivery.isRoomMessage
|
||||
let isChannelMessage = delivery.isChannelMessage
|
||||
|
||||
if shouldRetry {
|
||||
pendingLock.unlock()
|
||||
@@ -221,7 +221,7 @@ class DeliveryTracker {
|
||||
retryDelivery(messageID: messageID)
|
||||
} else {
|
||||
// Mark as failed
|
||||
let reason = isRoomMessage ? "No response from room members" : "Message not delivered"
|
||||
let reason = isChannelMessage ? "No response from channel members" : "Message not delivered"
|
||||
pendingDeliveries.removeValue(forKey: messageID)
|
||||
pendingLock.unlock()
|
||||
updateDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||
@@ -242,7 +242,7 @@ class DeliveryTracker {
|
||||
recipientID: delivery.recipientID,
|
||||
recipientNickname: delivery.recipientNickname,
|
||||
retryCount: delivery.retryCount + 1,
|
||||
isRoomMessage: delivery.isRoomMessage,
|
||||
isChannelMessage: delivery.isChannelMessage,
|
||||
isFavorite: delivery.isFavorite,
|
||||
ackedBy: delivery.ackedBy,
|
||||
expectedRecipients: delivery.expectedRecipients,
|
||||
|
||||
@@ -17,24 +17,24 @@ class KeychainManager {
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Room Passwords
|
||||
// MARK: - Channel Passwords
|
||||
|
||||
func saveRoomPassword(_ password: String, for room: String) -> Bool {
|
||||
let key = "room_\(room)"
|
||||
func saveChannelPassword(_ password: String, for channel: String) -> Bool {
|
||||
let key = "channel_\(channel)"
|
||||
return save(password, forKey: key)
|
||||
}
|
||||
|
||||
func getRoomPassword(for room: String) -> String? {
|
||||
let key = "room_\(room)"
|
||||
func getChannelPassword(for channel: String) -> String? {
|
||||
let key = "channel_\(channel)"
|
||||
return retrieve(forKey: key)
|
||||
}
|
||||
|
||||
func deleteRoomPassword(for room: String) -> Bool {
|
||||
let key = "room_\(room)"
|
||||
func deleteChannelPassword(for channel: String) -> Bool {
|
||||
let key = "channel_\(channel)"
|
||||
return delete(forKey: key)
|
||||
}
|
||||
|
||||
func getAllRoomPasswords() -> [String: String] {
|
||||
func getAllChannelPasswords() -> [String: String] {
|
||||
var passwords: [String: String] = [:]
|
||||
|
||||
// Query all items
|
||||
@@ -56,11 +56,11 @@ class KeychainManager {
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
if let account = item[kSecAttrAccount as String] as? String,
|
||||
account.hasPrefix("room_"),
|
||||
account.hasPrefix("channel_"),
|
||||
let data = item[kSecValueData as String] as? Data,
|
||||
let password = String(data: data, encoding: .utf8) {
|
||||
let room = String(account.dropFirst(5)) // Remove "room_" prefix
|
||||
passwords[room] = password
|
||||
let channel = String(account.dropFirst(8)) // Remove "channel_" prefix
|
||||
passwords[channel] = password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct StoredMessage: Codable {
|
||||
let senderPeerID: String?
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
let roomTag: String?
|
||||
let channelTag: String?
|
||||
let isPrivate: Bool
|
||||
let recipientPeerID: String?
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class MessageRetentionService {
|
||||
|
||||
private let documentsDirectory: URL
|
||||
private let messagesDirectory: URL
|
||||
private let favoriteRoomsKey = "bitchat.favoriteRooms"
|
||||
private let favoriteChannelsKey = "bitchat.favoriteChannels"
|
||||
private let retentionDays = 7 // Messages retained for 7 days
|
||||
private let encryptionKey: SymmetricKey
|
||||
|
||||
@@ -50,32 +50,32 @@ class MessageRetentionService {
|
||||
cleanupOldMessages()
|
||||
}
|
||||
|
||||
// MARK: - Favorite Rooms Management
|
||||
// MARK: - Favorite Channels Management
|
||||
|
||||
func getFavoriteRooms() -> Set<String> {
|
||||
let rooms = UserDefaults.standard.stringArray(forKey: favoriteRoomsKey) ?? []
|
||||
return Set(rooms)
|
||||
func getFavoriteChannels() -> Set<String> {
|
||||
let channels = UserDefaults.standard.stringArray(forKey: favoriteChannelsKey) ?? []
|
||||
return Set(channels)
|
||||
}
|
||||
|
||||
func toggleFavoriteRoom(_ room: String) -> Bool {
|
||||
var favorites = getFavoriteRooms()
|
||||
if favorites.contains(room) {
|
||||
favorites.remove(room)
|
||||
// Clean up messages for this room
|
||||
deleteMessagesForRoom(room)
|
||||
func toggleFavoriteChannel(_ channel: String) -> Bool {
|
||||
var favorites = getFavoriteChannels()
|
||||
if favorites.contains(channel) {
|
||||
favorites.remove(channel)
|
||||
// Clean up messages for this channel
|
||||
deleteMessagesForChannel(channel)
|
||||
} else {
|
||||
favorites.insert(room)
|
||||
favorites.insert(channel)
|
||||
}
|
||||
UserDefaults.standard.set(Array(favorites), forKey: favoriteRoomsKey)
|
||||
return favorites.contains(room)
|
||||
UserDefaults.standard.set(Array(favorites), forKey: favoriteChannelsKey)
|
||||
return favorites.contains(channel)
|
||||
}
|
||||
|
||||
// MARK: - Message Storage
|
||||
|
||||
func saveMessage(_ message: BitchatMessage, forRoom room: String?) {
|
||||
// Only save messages for favorite rooms
|
||||
guard let room = room ?? message.room,
|
||||
getFavoriteRooms().contains(room) else {
|
||||
func saveMessage(_ message: BitchatMessage, forChannel channel: String?) {
|
||||
// Only save messages for favorite channels
|
||||
guard let channel = channel ?? message.channel,
|
||||
getFavoriteChannels().contains(channel) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class MessageRetentionService {
|
||||
senderPeerID: message.senderPeerID,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
roomTag: message.room,
|
||||
channelTag: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.senderPeerID
|
||||
)
|
||||
@@ -98,22 +98,22 @@ class MessageRetentionService {
|
||||
guard let encryptedData = encrypt(messageData) else { return }
|
||||
|
||||
// Save to file
|
||||
let fileName = "\(room)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
|
||||
let fileName = "\(channel)_\(message.timestamp.timeIntervalSince1970)_\(message.id).enc"
|
||||
let fileURL = messagesDirectory.appendingPathComponent(fileName)
|
||||
|
||||
try? encryptedData.write(to: fileURL)
|
||||
}
|
||||
|
||||
func loadMessagesForRoom(_ room: String) -> [BitchatMessage] {
|
||||
guard getFavoriteRooms().contains(room) else { return [] }
|
||||
func loadMessagesForChannel(_ channel: String) -> [BitchatMessage] {
|
||||
guard getFavoriteChannels().contains(channel) else { return [] }
|
||||
|
||||
var messages: [BitchatMessage] = []
|
||||
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
|
||||
let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
|
||||
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
|
||||
|
||||
for fileURL in roomFiles {
|
||||
for fileURL in channelFiles {
|
||||
if let encryptedData = try? Data(contentsOf: fileURL),
|
||||
let decryptedData = decrypt(encryptedData),
|
||||
let storedMessage = try? JSONDecoder().decode(StoredMessage.self, from: decryptedData) {
|
||||
@@ -128,7 +128,7 @@ class MessageRetentionService {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: storedMessage.senderPeerID,
|
||||
mentions: nil,
|
||||
room: storedMessage.roomTag
|
||||
channel: storedMessage.channelTag
|
||||
)
|
||||
|
||||
messages.append(message)
|
||||
@@ -179,12 +179,12 @@ class MessageRetentionService {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessagesForRoom(_ room: String) {
|
||||
func deleteMessagesForChannel(_ channel: String) {
|
||||
do {
|
||||
let files = try FileManager.default.contentsOfDirectory(at: messagesDirectory, includingPropertiesForKeys: nil)
|
||||
let roomFiles = files.filter { $0.lastPathComponent.hasPrefix("\(room)_") }
|
||||
let channelFiles = files.filter { $0.lastPathComponent.hasPrefix("\(channel)_") }
|
||||
|
||||
for fileURL in roomFiles {
|
||||
for fileURL in channelFiles {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -200,7 +200,7 @@ class MessageRetentionService {
|
||||
} catch {
|
||||
}
|
||||
|
||||
// Clear favorite rooms
|
||||
UserDefaults.standard.removeObject(forKey: favoriteRoomsKey)
|
||||
// Clear favorite channels
|
||||
UserDefaults.standard.removeObject(forKey: favoriteChannelsKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ struct RetryableMessage {
|
||||
let id: String
|
||||
let content: String
|
||||
let mentions: [String]?
|
||||
let room: String?
|
||||
let channel: String?
|
||||
let isPrivate: Bool
|
||||
let recipientPeerID: String?
|
||||
let recipientNickname: String?
|
||||
let roomKey: Data?
|
||||
let channelKey: Data?
|
||||
let retryCount: Int
|
||||
let maxRetries: Int = 3
|
||||
let nextRetryTime: Date
|
||||
@@ -51,11 +51,11 @@ class MessageRetryService {
|
||||
func addMessageForRetry(
|
||||
content: String,
|
||||
mentions: [String]? = nil,
|
||||
room: String? = nil,
|
||||
channel: String? = nil,
|
||||
isPrivate: Bool = false,
|
||||
recipientPeerID: String? = nil,
|
||||
recipientNickname: String? = nil,
|
||||
roomKey: Data? = nil
|
||||
channelKey: Data? = nil
|
||||
) {
|
||||
// Don't queue if we're at capacity
|
||||
guard retryQueue.count < maxQueueSize else {
|
||||
@@ -66,11 +66,11 @@ class MessageRetryService {
|
||||
id: UUID().uuidString,
|
||||
content: content,
|
||||
mentions: mentions,
|
||||
room: room,
|
||||
channel: channel,
|
||||
isPrivate: isPrivate,
|
||||
recipientPeerID: recipientPeerID,
|
||||
recipientNickname: recipientNickname,
|
||||
roomKey: roomKey,
|
||||
channelKey: channelKey,
|
||||
retryCount: 0,
|
||||
nextRetryTime: Date().addingTimeInterval(retryInterval)
|
||||
)
|
||||
@@ -122,26 +122,26 @@ class MessageRetryService {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.recipientPeerID,
|
||||
recipientNickname: message.recipientNickname,
|
||||
roomKey: message.roomKey,
|
||||
channelKey: message.channelKey,
|
||||
retryCount: message.retryCount + 1,
|
||||
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
|
||||
)
|
||||
retryQueue.append(updatedMessage)
|
||||
}
|
||||
} else if let room = message.room, let roomKeyData = message.roomKey {
|
||||
// For room messages, check if we have peers in the room
|
||||
} else if let channel = message.channel, let channelKeyData = message.channelKey {
|
||||
// For channel messages, check if we have peers in the channel
|
||||
if !connectedPeers.isEmpty {
|
||||
// Recreate SymmetricKey from data
|
||||
let roomKey = SymmetricKey(data: roomKeyData)
|
||||
meshService.sendEncryptedRoomMessage(
|
||||
let channelKey = SymmetricKey(data: channelKeyData)
|
||||
meshService.sendEncryptedChannelMessage(
|
||||
message.content,
|
||||
mentions: message.mentions ?? [],
|
||||
room: room,
|
||||
roomKey: roomKey
|
||||
channel: channel,
|
||||
channelKey: channelKey
|
||||
)
|
||||
} else {
|
||||
// No peers connected, keep in queue
|
||||
@@ -150,11 +150,11 @@ class MessageRetryService {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.recipientPeerID,
|
||||
recipientNickname: message.recipientNickname,
|
||||
roomKey: message.roomKey,
|
||||
channelKey: message.channelKey,
|
||||
retryCount: message.retryCount + 1,
|
||||
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
|
||||
)
|
||||
@@ -166,7 +166,7 @@ class MessageRetryService {
|
||||
meshService.sendMessage(
|
||||
message.content,
|
||||
mentions: message.mentions ?? [],
|
||||
room: message.room
|
||||
channel: message.channel
|
||||
)
|
||||
} else {
|
||||
// No peers connected, keep in queue
|
||||
@@ -175,11 +175,11 @@ class MessageRetryService {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
mentions: message.mentions,
|
||||
room: message.room,
|
||||
channel: message.channel,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.recipientPeerID,
|
||||
recipientNickname: message.recipientNickname,
|
||||
roomKey: message.roomKey,
|
||||
channelKey: message.channelKey,
|
||||
retryCount: message.retryCount + 1,
|
||||
nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2))
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
let title = "Mentioned by \(sender)"
|
||||
let title = "@🫵 you were mentioned by \(sender)"
|
||||
let body = message
|
||||
let identifier = "mention-\(UUID().uuidString)"
|
||||
|
||||
@@ -69,7 +69,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
func sendPrivateMessageNotification(from sender: String, message: String) {
|
||||
let title = "Private message from \(sender)"
|
||||
let title = "🔒 private message from \(sender)"
|
||||
let body = message
|
||||
let identifier = "private-\(UUID().uuidString)"
|
||||
|
||||
@@ -83,4 +83,4 @@ class NotificationService {
|
||||
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,11 +65,11 @@ struct AppInfoView: View {
|
||||
FeatureRow(icon: "at", title: "Mentions",
|
||||
description: "Use @nickname to notify specific users")
|
||||
|
||||
FeatureRow(icon: "number", title: "Rooms",
|
||||
description: "Create #rooms for topic-based conversations")
|
||||
FeatureRow(icon: "number", title: "Channels",
|
||||
description: "Create #channels for topic-based conversations")
|
||||
|
||||
FeatureRow(icon: "lock.fill", title: "Password Rooms",
|
||||
description: "Secure rooms with passwords and AES encryption")
|
||||
FeatureRow(icon: "lock.fill", title: "Password Channels",
|
||||
description: "Secure channels with passwords and AES encryption")
|
||||
}
|
||||
|
||||
// Privacy
|
||||
@@ -92,10 +92,10 @@ struct AppInfoView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("• Set your nickname in the header")
|
||||
Text("• Swipe left or tap room name for sidebar")
|
||||
Text("• Swipe left or tap channel name for sidebar")
|
||||
Text("• Tap a peer to start a private chat")
|
||||
Text("• Use @nickname to mention someone")
|
||||
Text("• Use #roomname to create/join rooms")
|
||||
Text("• Use #channelname to create/join channels")
|
||||
Text("• Triple-tap the logo for panic mode")
|
||||
}
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -113,7 +113,7 @@ struct AppInfoView: View {
|
||||
Text("Store & Forward: 12h for all, ∞ for favorites")
|
||||
Text("Battery: Adaptive scanning based on level")
|
||||
Text("Platform: Universal (iOS, iPadOS, macOS)")
|
||||
Text("Rooms: Password-protected with key commitments")
|
||||
Text("Channels: Password-protected with key commitments")
|
||||
Text("Storage: Keychain for passwords, encrypted retention")
|
||||
}
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -171,11 +171,11 @@ struct AppInfoView: View {
|
||||
FeatureRow(icon: "at", title: "Mentions",
|
||||
description: "Use @nickname to notify specific users")
|
||||
|
||||
FeatureRow(icon: "number", title: "Rooms",
|
||||
description: "Create #rooms for topic-based conversations")
|
||||
FeatureRow(icon: "number", title: "Channels",
|
||||
description: "Create #channels for topic-based conversations")
|
||||
|
||||
FeatureRow(icon: "lock.fill", title: "Password Rooms",
|
||||
description: "Secure rooms with passwords and AES encryption")
|
||||
FeatureRow(icon: "lock.fill", title: "Password Channels",
|
||||
description: "Secure channels with passwords and AES encryption")
|
||||
}
|
||||
|
||||
// Privacy
|
||||
@@ -198,10 +198,10 @@ struct AppInfoView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("• Set your nickname in the header")
|
||||
Text("• Swipe left or tap room name for sidebar")
|
||||
Text("• Swipe left or tap channel name for sidebar")
|
||||
Text("• Tap a peer to start a private chat")
|
||||
Text("• Use @nickname to mention someone")
|
||||
Text("• Use #roomname to create/join rooms")
|
||||
Text("• Use #channelname to create/join channels")
|
||||
Text("• Triple-tap the logo for panic mode")
|
||||
}
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -219,7 +219,7 @@ struct AppInfoView: View {
|
||||
Text("Store & Forward: 12h for all, ∞ for favorites")
|
||||
Text("Battery: Adaptive scanning based on level")
|
||||
Text("Platform: Universal (iOS, iPadOS, macOS)")
|
||||
Text("Rooms: Password-protected with key commitments")
|
||||
Text("Channels: Password-protected with key commitments")
|
||||
Text("Storage: Keychain for passwords, encrypted retention")
|
||||
}
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
|
||||
+348
-333
@@ -19,7 +19,7 @@ struct ContentView: View {
|
||||
@State private var sidebarDragOffset: CGFloat = 0
|
||||
@State private var showAppInfo = false
|
||||
@State private var showPasswordInput = false
|
||||
@State private var passwordInputRoom: String? = nil
|
||||
@State private var passwordInputChannel: String? = nil
|
||||
@State private var passwordInput = ""
|
||||
@State private var showPasswordPrompt = false
|
||||
@State private var passwordPromptInput = ""
|
||||
@@ -112,55 +112,6 @@ struct ContentView: View {
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete overlay
|
||||
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
|
||||
GeometryReader { geometry in
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
// Calculate approximate position based on nickname length and @ position
|
||||
let nicknameWidth: CGFloat = viewModel.selectedPrivateChatPeer != nil ? 90 : 80
|
||||
let charWidth: CGFloat = 8.5 // Approximate width of monospace character
|
||||
let atPosition = CGFloat(viewModel.autocompleteRange?.location ?? 0)
|
||||
let offsetX = nicknameWidth + (atPosition * charWidth)
|
||||
|
||||
// Ensure offsetX is valid (not NaN or infinite)
|
||||
let safeOffsetX = offsetX.isFinite ? offsetX : nicknameWidth
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
|
||||
Button(action: {
|
||||
_ = viewModel.completeNickname(suggestion, in: &messageText)
|
||||
}) {
|
||||
HStack {
|
||||
Text("@\(suggestion)")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(index == viewModel.selectedAutocompleteIndex ? backgroundColor : textColor)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(index == viewModel.selectedAutocompleteIndex ? textColor : Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 150, alignment: .leading)
|
||||
.offset(x: min(safeOffsetX, max(0, geometry.size.width - 180))) // Prevent going off-screen
|
||||
.padding(.bottom, 45) // Position just above input
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
@@ -168,34 +119,34 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
}
|
||||
.alert("Set Room Password", isPresented: $showPasswordInput) {
|
||||
.alert("Set Channel Password", isPresented: $showPasswordInput) {
|
||||
SecureField("Password", text: $passwordInput)
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordInput = ""
|
||||
passwordInputRoom = nil
|
||||
passwordInputChannel = nil
|
||||
}
|
||||
Button("Set Password") {
|
||||
if let room = passwordInputRoom, !passwordInput.isEmpty {
|
||||
viewModel.setRoomPassword(passwordInput, for: room)
|
||||
if let channel = passwordInputChannel, !passwordInput.isEmpty {
|
||||
viewModel.setChannelPassword(passwordInput, for: channel)
|
||||
passwordInput = ""
|
||||
passwordInputRoom = nil
|
||||
passwordInputChannel = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a password to protect \(passwordInputRoom ?? "room"). Others will need this password to read messages.")
|
||||
Text("Enter a password to protect \(passwordInputChannel ?? "channel"). Others will need this password to read messages.")
|
||||
}
|
||||
.alert("Enter Room Password", isPresented: Binding(
|
||||
.alert("Enter Channel Password", isPresented: Binding(
|
||||
get: { viewModel.showPasswordPrompt },
|
||||
set: { viewModel.showPasswordPrompt = $0 }
|
||||
)) {
|
||||
SecureField("Password", text: $passwordPromptInput)
|
||||
Button("Cancel", role: .cancel) {
|
||||
passwordPromptInput = ""
|
||||
viewModel.passwordPromptRoom = nil
|
||||
viewModel.passwordPromptChannel = nil
|
||||
}
|
||||
Button("Join") {
|
||||
if let room = viewModel.passwordPromptRoom, !passwordPromptInput.isEmpty {
|
||||
let success = viewModel.joinRoom(room, password: passwordPromptInput)
|
||||
if let channel = viewModel.passwordPromptChannel, !passwordPromptInput.isEmpty {
|
||||
let success = viewModel.joinChannel(channel, password: passwordPromptInput)
|
||||
if success {
|
||||
passwordPromptInput = ""
|
||||
} else {
|
||||
@@ -206,7 +157,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Room \(viewModel.passwordPromptRoom ?? "") is password protected. Enter the password to join.")
|
||||
Text("Channel \(viewModel.passwordPromptChannel ?? "") is password protected. Enter the password to join.")
|
||||
}
|
||||
.alert("Wrong Password", isPresented: $showPasswordError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
@@ -256,10 +207,10 @@ struct ContentView: View {
|
||||
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if let currentRoom = viewModel.currentRoom {
|
||||
// Room header
|
||||
} else if let currentChannel = viewModel.currentChannel {
|
||||
// Channel header
|
||||
Button(action: {
|
||||
viewModel.switchToRoom(nil)
|
||||
viewModel.switchToChannel(nil)
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
@@ -280,14 +231,14 @@ struct ContentView: View {
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
Text("room: \(currentRoom)")
|
||||
Text("channel: \(currentChannel)")
|
||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.orange : Color.blue)
|
||||
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -297,48 +248,48 @@ struct ContentView: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
// Show retention indicator for all users
|
||||
if viewModel.retentionEnabledRooms.contains(currentRoom) {
|
||||
if viewModel.retentionEnabledChannels.contains(currentChannel) {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(Color.yellow)
|
||||
.help("Messages in this room are being saved locally")
|
||||
.help("Messages in this channel are being saved locally")
|
||||
}
|
||||
|
||||
// Save button - only for room owner
|
||||
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
|
||||
// Save button - only for channel owner
|
||||
if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
viewModel.sendMessage("/save")
|
||||
}) {
|
||||
Image(systemName: viewModel.retentionEnabledRooms.contains(currentRoom) ? "bookmark.slash" : "bookmark")
|
||||
Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(viewModel.retentionEnabledRooms.contains(currentRoom) ? "Disable message retention" : "Enable message retention")
|
||||
.help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention")
|
||||
}
|
||||
|
||||
// Password button for room creator only
|
||||
if viewModel.roomCreators[currentRoom] == viewModel.meshService.myPeerID {
|
||||
// Password button for channel creator only
|
||||
if viewModel.channelCreators[currentChannel] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
// Toggle password protection
|
||||
if viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
viewModel.removeRoomPassword(for: currentRoom)
|
||||
if viewModel.passwordProtectedChannels.contains(currentChannel) {
|
||||
viewModel.removeChannelPassword(for: currentChannel)
|
||||
} else {
|
||||
// Show password input
|
||||
showPasswordInput = true
|
||||
passwordInputRoom = currentRoom
|
||||
passwordInputChannel = currentChannel
|
||||
}
|
||||
}) {
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(currentRoom) ? "lock.fill" : "lock")
|
||||
Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(currentRoom) ? Color.yellow : textColor)
|
||||
.foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Leave room button
|
||||
// Leave channel button
|
||||
Button(action: {
|
||||
viewModel.leaveRoom(currentRoom)
|
||||
viewModel.leaveChannel(currentChannel)
|
||||
}) {
|
||||
Text("leave")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
@@ -384,10 +335,10 @@ struct ContentView: View {
|
||||
|
||||
// People counter with unread indicator
|
||||
HStack(spacing: 4) {
|
||||
// Check for any unread room messages
|
||||
let hasUnreadRoomMessages = viewModel.unreadRoomMessages.values.contains { $0 > 0 }
|
||||
// Check for any unread channel messages
|
||||
let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 }
|
||||
|
||||
if hasUnreadRoomMessages {
|
||||
if hasUnreadChannelMessages {
|
||||
Image(systemName: "number")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color.blue)
|
||||
@@ -400,17 +351,26 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count
|
||||
let roomCount = viewModel.joinedRooms.count
|
||||
let statusText = if !viewModel.isConnected {
|
||||
"alone :/"
|
||||
} else if roomCount > 0 {
|
||||
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")/\(roomCount) \(roomCount == 1 ? "room" : "rooms")"
|
||||
} else {
|
||||
"\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")"
|
||||
let channelCount = viewModel.joinedChannels.count
|
||||
|
||||
HStack(spacing: 4) {
|
||||
// People icon with count
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 11))
|
||||
Text("\(otherPeersCount)")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
|
||||
// Channels icon with count (only if there are channels)
|
||||
if channelCount > 0 {
|
||||
Text("·")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
Image(systemName: "square.split.2x2")
|
||||
.font(.system(size: 11))
|
||||
Text("\(channelCount)")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
Text(statusText)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
|
||||
.foregroundColor(viewModel.isConnected ? textColor : Color.red)
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
@@ -435,8 +395,8 @@ struct ContentView: View {
|
||||
// Log what we're showing
|
||||
// Removed debug logging
|
||||
return msgs
|
||||
} else if let currentRoom = viewModel.currentRoom {
|
||||
return viewModel.getRoomMessages(currentRoom)
|
||||
} else if let currentChannel = viewModel.currentChannel {
|
||||
return viewModel.getChannelMessages(currentChannel)
|
||||
} else {
|
||||
return viewModel.messages
|
||||
}
|
||||
@@ -445,64 +405,29 @@ struct ContentView: View {
|
||||
ForEach(messages, id: \.id) { message in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Check if current user is mentioned
|
||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||
let _ = message.mentions?.contains(viewModel.nickname) ?? false
|
||||
|
||||
if message.sender == "system" {
|
||||
// System messages
|
||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
// Regular messages with tappable sender name
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
// Timestamp
|
||||
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
// Regular messages with natural text wrapping
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Single text view for natural wrapping
|
||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||
.textSelection(.enabled)
|
||||
|
||||
// Tappable sender name
|
||||
if message.sender != viewModel.nickname {
|
||||
Button(action: {
|
||||
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
}) {
|
||||
let senderColor = viewModel.getSenderColor(for: message, colorScheme: colorScheme)
|
||||
Text("<@\(message.sender)>")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(senderColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// Own messages not tappable
|
||||
Text("<@\(message.sender)>")
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Text(" ")
|
||||
|
||||
// Message content with clickable hashtags
|
||||
MessageContentView(
|
||||
message: message,
|
||||
viewModel: viewModel,
|
||||
colorScheme: colorScheme,
|
||||
isMentioned: isMentioned
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
.alignmentGuide(.firstTextBaseline) { _ in 12 }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,45 +484,19 @@ struct ContentView: View {
|
||||
|
||||
private var inputView: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Command suggestions
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
// @mentions autocomplete
|
||||
if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
let baseCommands: [String: String] = [
|
||||
"/j": "join or create a room",
|
||||
"/rooms": "show all discovered rooms",
|
||||
"/w": "see who's online",
|
||||
"/m": "send private message",
|
||||
"/clear": "clear chat messages"
|
||||
]
|
||||
|
||||
let roomCommands: [String: String] = [
|
||||
"/transfer": "transfer room ownership",
|
||||
"/pass": "change room password",
|
||||
"/save": "save room messages locally"
|
||||
]
|
||||
|
||||
let commandDescriptions = viewModel.currentRoom != nil
|
||||
? baseCommands.merging(roomCommands) { (_, new) in new }
|
||||
: baseCommands
|
||||
|
||||
ForEach(commandSuggestions, id: \.self) { command in
|
||||
ForEach(Array(viewModel.autocompleteSuggestions.enumerated()), id: \.element) { index, suggestion in
|
||||
Button(action: {
|
||||
// Replace current text with selected command
|
||||
messageText = command + " "
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
_ = viewModel.completeNickname(suggestion, in: &messageText)
|
||||
}) {
|
||||
HStack {
|
||||
Text(command)
|
||||
Text("@\(suggestion)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
if let description = commandDescriptions[command] {
|
||||
Text(description)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
@@ -613,7 +512,79 @@ struct ContentView: View {
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// Command suggestions
|
||||
if showCommandSuggestions && !commandSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Define commands with aliases and syntax
|
||||
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/clear"], nil, "clear chat messages"),
|
||||
(["/hug"], "<nickname>", "send someone a warm hug"),
|
||||
(["/j", "/join"], "<channel>", "join or create a channel"),
|
||||
(["/m", "/msg"], "<nickname> [message]", "send private message"),
|
||||
(["/channels"], nil, "show all discovered channels"),
|
||||
(["/slap"], "<nickname>", "slap someone with a trout"),
|
||||
(["/w"], nil, "see who's online")
|
||||
]
|
||||
|
||||
let channelCommandInfo: [(commands: [String], syntax: String?, description: String)] = [
|
||||
(["/pass"], "[password]", "change channel password"),
|
||||
(["/save"], nil, "save channel messages locally"),
|
||||
(["/transfer"], "<nickname>", "transfer channel ownership")
|
||||
]
|
||||
|
||||
// Build the display
|
||||
let allCommands = viewModel.currentChannel != nil
|
||||
? commandInfo + channelCommandInfo
|
||||
: commandInfo
|
||||
|
||||
// Show matching commands
|
||||
ForEach(commandSuggestions, id: \.self) { command in
|
||||
// Find the command info for this suggestion
|
||||
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
|
||||
Button(action: {
|
||||
// Replace current text with selected command
|
||||
messageText = command + " "
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}) {
|
||||
HStack {
|
||||
// Show all aliases together
|
||||
Text(info.commands.joined(separator: ", "))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.fontWeight(.medium)
|
||||
|
||||
// Show syntax if any
|
||||
if let syntax = info.syntax {
|
||||
Text(syntax)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Show description
|
||||
Text(info.description)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
@@ -624,7 +595,7 @@ struct ContentView: View {
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.padding(.leading, 12)
|
||||
} else if let currentRoom = viewModel.currentRoom, viewModel.passwordProtectedRooms.contains(currentRoom) {
|
||||
} else if let currentChannel = viewModel.currentChannel, viewModel.passwordProtectedChannels.contains(currentChannel) {
|
||||
Text("<@\(viewModel.nickname)> →")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
@@ -654,24 +625,46 @@ struct ContentView: View {
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
var commandDescriptions = [
|
||||
("/j", "join or create a room"),
|
||||
("/rooms", "show all discovered rooms"),
|
||||
("/w", "see who's online"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/j", "join or create a channel"),
|
||||
("/m", "send private message"),
|
||||
("/clear", "clear chat messages")
|
||||
("/channels", "show all discovered channels"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
|
||||
// Add room-specific commands if in a room
|
||||
if viewModel.currentRoom != nil {
|
||||
commandDescriptions.append(("/transfer", "transfer room ownership"))
|
||||
commandDescriptions.append(("/pass", "change room password"))
|
||||
commandDescriptions.append(("/save", "save room messages locally"))
|
||||
// Add channel-specific commands if in a channel
|
||||
if viewModel.currentChannel != nil {
|
||||
commandDescriptions.append(("/pass", "change channel password"))
|
||||
commandDescriptions.append(("/save", "save channel messages locally"))
|
||||
commandDescriptions.append(("/transfer", "transfer channel ownership"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
@@ -686,7 +679,7 @@ struct ContentView: View {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor((viewModel.selectedPrivateChatPeer != nil ||
|
||||
(viewModel.currentRoom != nil && viewModel.passwordProtectedRooms.contains(viewModel.currentRoom ?? "")))
|
||||
(viewModel.currentChannel != nil && viewModel.passwordProtectedChannels.contains(viewModel.currentChannel ?? "")))
|
||||
? Color.orange : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -705,6 +698,128 @@ struct ContentView: View {
|
||||
messageText = ""
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var channelsSection: some View {
|
||||
if !viewModel.joinedChannels.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "square.split.2x2")
|
||||
.font(.system(size: 10))
|
||||
Text("CHANNELS")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
ForEach(Array(viewModel.joinedChannels).sorted(), id: \.self) { channel in
|
||||
channelButton(for: channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func channelButton(for channel: String) -> some View {
|
||||
Button(action: {
|
||||
// Check if channel needs password and we don't have it
|
||||
if viewModel.passwordProtectedChannels.contains(channel) && viewModel.channelKeys[channel] == nil {
|
||||
// Need password
|
||||
viewModel.passwordPromptChannel = channel
|
||||
viewModel.showPasswordPrompt = true
|
||||
} else {
|
||||
// Can enter channel
|
||||
viewModel.switchToChannel(channel)
|
||||
withAnimation(.spring()) {
|
||||
showSidebar = false
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
// Lock icon for password protected channels
|
||||
if viewModel.passwordProtectedChannels.contains(channel) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
|
||||
Text(channel)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(viewModel.currentChannel == channel ? Color.blue : textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Unread count
|
||||
if let unreadCount = viewModel.unreadChannelMessages[channel], unreadCount > 0 {
|
||||
Text("\(unreadCount)")
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(backgroundColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Channel controls
|
||||
if viewModel.currentChannel == channel {
|
||||
channelControls(for: channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(viewModel.currentChannel == channel ? backgroundColor.opacity(0.5) : Color.clear)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func channelControls(for channel: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
// Password button for channel creator only
|
||||
if viewModel.channelCreators[channel] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
// Toggle password protection
|
||||
if viewModel.passwordProtectedChannels.contains(channel) {
|
||||
viewModel.removeChannelPassword(for: channel)
|
||||
} else {
|
||||
// Show password input
|
||||
showPasswordInput = true
|
||||
passwordInputChannel = channel
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: viewModel.passwordProtectedChannels.contains(channel) ? "lock.fill" : "lock")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(viewModel.passwordProtectedChannels.contains(channel) ? backgroundColor : secondaryTextColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(viewModel.passwordProtectedChannels.contains(channel) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Leave button
|
||||
Button(action: {
|
||||
viewModel.leaveChannel(channel)
|
||||
}) {
|
||||
Text("leave channel")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarView: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Grey vertical bar for visual continuity
|
||||
@@ -715,7 +830,7 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header - match main toolbar height
|
||||
HStack {
|
||||
Text("connected")
|
||||
Text("YOUR NETWORK")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
@@ -729,111 +844,10 @@ struct ContentView: View {
|
||||
// Rooms and People list
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Joined Rooms section
|
||||
if !viewModel.joinedRooms.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("ROOMS")
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
ForEach(Array(viewModel.joinedRooms).sorted(), id: \.self) { room in
|
||||
Button(action: {
|
||||
// Check if room needs password and we don't have it
|
||||
if viewModel.passwordProtectedRooms.contains(room) && viewModel.roomKeys[room] == nil {
|
||||
// Need password
|
||||
viewModel.passwordPromptRoom = room
|
||||
viewModel.showPasswordPrompt = true
|
||||
} else {
|
||||
// Can enter room
|
||||
viewModel.switchToRoom(room)
|
||||
withAnimation(.spring()) {
|
||||
showSidebar = false
|
||||
}
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
// Lock icon for password protected rooms
|
||||
if viewModel.passwordProtectedRooms.contains(room) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
|
||||
Text(room)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(viewModel.currentRoom == room ? Color.blue : textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Unread count
|
||||
if let unreadCount = viewModel.unreadRoomMessages[room], unreadCount > 0 {
|
||||
Text("\(unreadCount)")
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(backgroundColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Room controls
|
||||
if viewModel.currentRoom == room {
|
||||
HStack(spacing: 4) {
|
||||
// Password button for room creator only
|
||||
if viewModel.roomCreators[room] == viewModel.meshService.myPeerID {
|
||||
Button(action: {
|
||||
// Toggle password protection
|
||||
if viewModel.passwordProtectedRooms.contains(room) {
|
||||
viewModel.removeRoomPassword(for: room)
|
||||
} else {
|
||||
// Show password input
|
||||
showPasswordInput = true
|
||||
passwordInputRoom = room
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: viewModel.passwordProtectedRooms.contains(room) ? "lock.fill" : "lock")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(viewModel.passwordProtectedRooms.contains(room) ? backgroundColor : secondaryTextColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(viewModel.passwordProtectedRooms.contains(room) ? Color.orange : secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Leave button
|
||||
Button(action: {
|
||||
viewModel.leaveRoom(room)
|
||||
}) {
|
||||
Text("leave room")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(secondaryTextColor.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(viewModel.currentRoom == room ? backgroundColor.opacity(0.5) : Color.clear)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Channels section
|
||||
channelsSection
|
||||
|
||||
if !viewModel.joinedChannels.isEmpty {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@@ -841,16 +855,20 @@ struct ContentView: View {
|
||||
// People section
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Show appropriate header based on context
|
||||
if let currentRoom = viewModel.currentRoom {
|
||||
Text("IN \(currentRoom.uppercased())")
|
||||
if let currentChannel = viewModel.currentChannel {
|
||||
Text("IN \(currentChannel.uppercased())")
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
} else if !viewModel.connectedPeers.isEmpty {
|
||||
Text("PEOPLE")
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 10))
|
||||
Text("PEOPLE")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
if viewModel.connectedPeers.isEmpty {
|
||||
@@ -858,10 +876,10 @@ struct ContentView: View {
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
} else if let currentRoom = viewModel.currentRoom,
|
||||
let roomMemberIDs = viewModel.roomMembers[currentRoom],
|
||||
roomMemberIDs.isEmpty {
|
||||
Text("No one in this room yet")
|
||||
} else if let currentChannel = viewModel.currentChannel,
|
||||
let channelMemberIDs = viewModel.channelMembers[currentChannel],
|
||||
channelMemberIDs.isEmpty {
|
||||
Text("No one in this channel yet")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
@@ -870,17 +888,17 @@ struct ContentView: View {
|
||||
let peerRSSI = viewModel.meshService.getPeerRSSI()
|
||||
let myPeerID = viewModel.meshService.myPeerID
|
||||
|
||||
// Filter peers based on current room
|
||||
// Filter peers based on current channel
|
||||
let peersToShow: [String] = {
|
||||
if let currentRoom = viewModel.currentRoom,
|
||||
let roomMemberIDs = viewModel.roomMembers[currentRoom] {
|
||||
// Show only peers who have sent messages to this room (including self)
|
||||
if let currentChannel = viewModel.currentChannel,
|
||||
let channelMemberIDs = viewModel.channelMembers[currentChannel] {
|
||||
// Show only peers who have sent messages to this channel (including self)
|
||||
|
||||
// Start with room members who are also connected
|
||||
var memberPeers = viewModel.connectedPeers.filter { roomMemberIDs.contains($0) }
|
||||
// Start with channel members who are also connected
|
||||
var memberPeers = viewModel.connectedPeers.filter { channelMemberIDs.contains($0) }
|
||||
|
||||
// Always include ourselves if we're a room member
|
||||
if roomMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
|
||||
// Always include ourselves if we're a channel member
|
||||
if channelMemberIDs.contains(myPeerID) && !memberPeers.contains(myPeerID) {
|
||||
memberPeers.append(myPeerID)
|
||||
}
|
||||
|
||||
@@ -1014,33 +1032,30 @@ struct MessageContentView: View {
|
||||
}
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
// Build the text view with clickable hashtags
|
||||
return HStack(spacing: 0) {
|
||||
ForEach(Array(buildTextSegments().enumerated()), id: \.offset) { _, segment in
|
||||
if segment.type == "hashtag" {
|
||||
Button(action: {
|
||||
_ = viewModel.joinRoom(segment.text)
|
||||
}) {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.underline()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if segment.type == "mention" {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
Text(segment.text)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
// Build the text as a concatenated Text view for natural wrapping
|
||||
let segments = buildTextSegments()
|
||||
var result = Text("")
|
||||
|
||||
for segment in segments {
|
||||
if segment.type == "hashtag" {
|
||||
// Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.underline()
|
||||
} else if segment.type == "mention" {
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
} else {
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func buildTextSegments() -> [(text: String, type: String)] {
|
||||
|
||||
Reference in New Issue
Block a user