From 3d7232222cf043c423c88295f1d2b337ce7c2051 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 4 Jul 2025 18:37:04 +0200 Subject: [PATCH] Implement hashtag rooms feature - Add room field to BitchatMessage structure - Update BinaryProtocol to encode/decode room information - Create room management in ChatViewModel (join/leave/switch rooms) - Add BluetoothMeshService support for room messages - Implement rooms UI in sidebar with unread counts - Create clickable hashtags in messages to join rooms - Add room persistence across app restarts - Filter message display by current room selection --- bitchat/Protocols/BinaryProtocol.swift | 22 +- bitchat/Protocols/BitchatProtocol.swift | 4 +- bitchat/Services/BluetoothMeshService.swift | 7 +- bitchat/ViewModels/ChatViewModel.swift | 183 ++++++++++++-- bitchat/Views/ContentView.swift | 257 ++++++++++++++++++-- 5 files changed, 426 insertions(+), 47 deletions(-) diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index 95470a83..756486e3 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -193,6 +193,7 @@ extension BitchatMessage { // - Recipient nickname length + data // - Sender peer ID length + data // - Mentions array + // - Room hashtag var flags: UInt8 = 0 if isRelay { flags |= 0x01 } @@ -201,6 +202,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 } data.append(flags) @@ -267,6 +269,12 @@ 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)) + } + return data } @@ -292,6 +300,7 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 + let hasRoom = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -389,6 +398,16 @@ extension BitchatMessage { } } + // Room + var room: String? = nil + if hasRoom && offset < dataCopy.count { + let length = Int(dataCopy[offset]); offset += 1 + if offset + length <= dataCopy.count { + room = String(data: dataCopy[offset.. = [] // Set of room hashtags + @Published var currentRoom: String? = nil // Currently selected room + @Published var roomMessages: [String: [BitchatMessage]] = [:] // room -> messages + @Published var unreadRoomMessages: [String: Int] = [:] // room -> unread count + let meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" private let favoritesKey = "bitchat.favorites" + private let joinedRoomsKey = "bitchat.joinedRooms" private var nicknameSaveTimer: Timer? @Published var favoritePeers: Set = [] // Now stores public key fingerprints instead of peer IDs @@ -48,6 +55,7 @@ class ChatViewModel: ObservableObject { init() { loadNickname() loadFavorites() + loadJoinedRooms() meshService.delegate = self // Start mesh service immediately @@ -85,6 +93,79 @@ class ChatViewModel: ObservableObject { userDefaults.synchronize() } + private func loadJoinedRooms() { + if let savedRooms = userDefaults.stringArray(forKey: joinedRoomsKey) { + joinedRooms = Set(savedRooms) + } + } + + private func saveJoinedRooms() { + userDefaults.set(Array(joinedRooms), forKey: joinedRoomsKey) + userDefaults.synchronize() + } + + func joinRoom(_ room: String) { + // Ensure room starts with # + let roomTag = room.hasPrefix("#") ? room : "#\(room)" + joinedRooms.insert(roomTag) + saveJoinedRooms() + + // Switch to the room + currentRoom = roomTag + selectedPrivateChatPeer = nil // Exit private chat if in one + + // Clear unread count for this room + unreadRoomMessages[roomTag] = 0 + + // Initialize room messages if needed + if roomMessages[roomTag] == nil { + roomMessages[roomTag] = [] + } + } + + func leaveRoom(_ room: String) { + joinedRooms.remove(room) + saveJoinedRooms() + + // If we're currently in this room, exit to main chat + if currentRoom == room { + currentRoom = nil + } + + // Keep messages for now (could clear if desired) + unreadRoomMessages.removeValue(forKey: room) + } + + func switchToRoom(_ room: String?) { + currentRoom = room + selectedPrivateChatPeer = nil // Exit private chat + + // Clear unread count for this room + if let room = room { + unreadRoomMessages[room] = 0 + } + } + + func getRoomMessages(_ room: String) -> [BitchatMessage] { + return roomMessages[room] ?? [] + } + + func parseRooms(from content: String) -> Set { + let pattern = "#([a-zA-Z0-9_]+)" + let regex = try? NSRegularExpression(pattern: pattern, options: []) + let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + + var rooms = Set() + for match in matches { + if let range = Range(match.range(at: 0), in: content) { + let room = String(content[range]) + rooms.insert(room) + } + } + + return rooms + } + func toggleFavorite(peerID: String) { // Use public key fingerprints for persistent favorites guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else { @@ -134,8 +215,12 @@ class ChatViewModel: ObservableObject { // Send as private message sendPrivateMessage(content, to: selectedPeer) } else { - // Parse mentions from the content + // Parse mentions and rooms from the content let mentions = parseMentions(from: content) + let rooms = parseRooms(from: content) + + // Determine which room this message belongs to + let messageRoom = currentRoom // Use current room if we're in one // Add message to local display let message = BitchatMessage( @@ -144,12 +229,30 @@ class ChatViewModel: ObservableObject { timestamp: Date(), isRelay: false, originalSender: nil, - mentions: mentions.isEmpty ? nil : mentions + mentions: mentions.isEmpty ? nil : mentions, + room: messageRoom ) - messages.append(message) - // Send via mesh with mentions - meshService.sendMessage(content, mentions: mentions) + if let room = messageRoom { + // Add to room messages + if roomMessages[room] == nil { + roomMessages[room] = [] + } + roomMessages[room]?.append(message) + } else { + // Add to main messages + messages.append(message) + } + + // Auto-join any new rooms mentioned in the message + for room in rooms { + if !joinedRooms.contains(room) { + joinRoom(room) + } + } + + // Send via mesh with mentions and room + meshService.sendMessage(content, mentions: mentions, room: messageRoom) } } @@ -349,16 +452,31 @@ class ChatViewModel: ObservableObject { let contentText = message.content var processedContent = AttributedString() - // Regular expression to find @mentions - let pattern = "@([a-zA-Z0-9_]+)" - let regex = try? NSRegularExpression(pattern: pattern, options: []) - let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] + // Regular expressions for mentions and hashtags + let mentionPattern = "@([a-zA-Z0-9_]+)" + let hashtagPattern = "#([a-zA-Z0-9_]+)" + + let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) + let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) + + let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] + let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] + + // Combine and sort all matches + var allMatches: [(range: NSRange, type: String)] = [] + for match in mentionMatches { + allMatches.append((match.range(at: 0), "mention")) + } + for match in hashtagMatches { + allMatches.append((match.range(at: 0), "hashtag")) + } + allMatches.sort { $0.range.location < $1.range.location } var lastEndIndex = contentText.startIndex - for match in matches { - // Add text before the mention - if let range = Range(match.range(at: 0), in: contentText) { + for (matchRange, matchType) in allMatches { + // Add text before the match + if let range = Range(matchRange, in: contentText) { let beforeText = String(contentText[lastEndIndex.. 0 { + Text("\(unreadCount)") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(backgroundColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange) + .clipShape(Capsule()) + } + + // Leave button + if viewModel.currentRoom == room { + Button(action: { + viewModel.leaveRoom(room) + }) { + Text("leave") + .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() + .padding(.vertical, 4) + } + + // People section + VStack(alignment: .leading, spacing: 8) { + if !viewModel.connectedPeers.isEmpty { + Text("PEOPLE") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal, 12) + } + + if viewModel.connectedPeers.isEmpty { + Text("No one connected") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal) + } else { + let peerNicknames = viewModel.meshService.getPeerNicknames() + let peerRSSI = viewModel.meshService.getPeerRSSI() + let myPeerID = viewModel.meshService.myPeerID + // Sort peers: favorites first, then alphabetically by nickname let sortedPeers = viewModel.connectedPeers.filter { $0 != myPeerID }.sorted { peer1, peer2 in let isFav1 = viewModel.isFavorite(peerID: peer1) @@ -502,14 +606,123 @@ struct ContentView: View { .padding(.horizontal) .padding(.vertical, 8) } + } } } .padding(.vertical, 8) } - Spacer() - } - .background(backgroundColor) + Spacer() + } + .background(backgroundColor) } } +} + +// Helper view for rendering message content with clickable hashtags +struct MessageContentView: View { + let message: BitchatMessage + let viewModel: ChatViewModel + let colorScheme: ColorScheme + let isMentioned: Bool + + var body: some View { + let content = message.content + let hashtagPattern = "#([a-zA-Z0-9_]+)" + let mentionPattern = "@([a-zA-Z0-9_]+)" + + let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) + let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) + + let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + + // Combine all matches and sort by location + var allMatches: [(range: NSRange, type: String)] = [] + for match in hashtagMatches { + allMatches.append((match.range(at: 0), "hashtag")) + } + for match in mentionMatches { + allMatches.append((match.range(at: 0), "mention")) + } + allMatches.sort { $0.range.location < $1.range.location } + + // Build 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() + } + .buttonStyle(.plain) + } else if segment.type == "mention" { + Text(segment.text) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundColor(Color.orange) + } else { + Text(segment.text) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) + } + } + } + } + + private func buildTextSegments() -> [(text: String, type: String)] { + var segments: [(text: String, type: String)] = [] + let content = message.content + var lastEnd = content.startIndex + + let hashtagPattern = "#([a-zA-Z0-9_]+)" + let mentionPattern = "@([a-zA-Z0-9_]+)" + + let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) + let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) + + let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] + + // Combine all matches and sort by location + var allMatches: [(range: NSRange, type: String)] = [] + for match in hashtagMatches { + allMatches.append((match.range(at: 0), "hashtag")) + } + for match in mentionMatches { + allMatches.append((match.range(at: 0), "mention")) + } + allMatches.sort { $0.range.location < $1.range.location } + + for (matchRange, matchType) in allMatches { + if let range = Range(matchRange, in: content) { + // Add text before the match + if lastEnd < range.lowerBound { + let beforeText = String(content[lastEnd..