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
This commit is contained in:
jack
2025-07-05 19:35:37 +02:00
parent bb2edd4e23
commit 3d7232222c
5 changed files with 426 additions and 47 deletions
+21 -1
View File
@@ -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..<offset+length], encoding: .utf8)
offset += length
}
}
let message = BitchatMessage(
sender: sender,
content: content,
@@ -398,7 +417,8 @@ extension BitchatMessage {
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
mentions: mentions,
room: room
)
return message
}
+3 -1
View File
@@ -133,8 +133,9 @@ 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")
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil) {
init(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) {
self.id = UUID().uuidString
self.sender = sender
self.content = content
@@ -145,6 +146,7 @@ struct BitchatMessage: Codable, Equatable {
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.room = room
}
}
+3 -4
View File
@@ -507,7 +507,7 @@ class BluetoothMeshService: NSObject {
self.characteristic = characteristic
}
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil) {
func sendMessage(_ content: String, mentions: [String] = [], room: String? = nil, to recipientID: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -520,7 +520,8 @@ class BluetoothMeshService: NSObject {
timestamp: Date(),
isRelay: false,
originalSender: nil,
mentions: mentions.isEmpty ? nil : mentions
mentions: mentions.isEmpty ? nil : mentions,
room: room
)
if let messageData = message.toBinaryPayload() {
@@ -847,11 +848,9 @@ class BluetoothMeshService: NSObject {
processedKeyExchanges = processedKeyExchanges.filter { !$0.contains(peerID) }
peerNicknamesLock.lock()
let nickname = peerNicknames[peerID]
peerNicknames.removeValue(forKey: peerID)
peerNicknamesLock.unlock()
let lastSeenAgo = peerLastSeenTimestamps[peerID].map { now.timeIntervalSince($0) } ?? 999
// Removed stale peer
}
activePeersLock.unlock()
+164 -19
View File
@@ -34,10 +34,17 @@ class ChatViewModel: ObservableObject {
@Published var autocompleteRange: NSRange? = nil
@Published var selectedAutocompleteIndex: Int = 0
// Room support
@Published var joinedRooms: Set<String> = [] // 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<String> = [] // 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<String> {
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<String>()
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..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
@@ -367,12 +485,20 @@ class ChatViewModel: ObservableObject {
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
// Add the mention with highlight
let mentionText = String(contentText[range])
var mentionStyle = AttributeContainer()
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
// Add the match with appropriate styling
let matchText = String(contentText[range])
var matchStyle = AttributeContainer()
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
if matchType == "mention" {
matchStyle.foregroundColor = Color.orange
} else {
// Hashtag
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
}
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
lastEndIndex = range.upperBound
}
@@ -517,8 +643,27 @@ extension ChatViewModel: BitchatDelegate {
} else if message.sender == nickname {
// Our own message that was echoed back - ignore it since we already added it locally
}
} else if let room = message.room {
// Room message
// Auto-join room if we see a message for it
if !joinedRooms.contains(room) {
joinRoom(room)
}
// Add to room messages
if roomMessages[room] == nil {
roomMessages[room] = []
}
roomMessages[room]?.append(message)
roomMessages[room]?.sort { $0.timestamp < $1.timestamp }
// Update unread count if not currently viewing this room
if currentRoom != room {
unreadRoomMessages[room] = (unreadRoomMessages[room] ?? 0) + 1
}
} else {
// Regular public message
// Regular public message (main chat)
messages.append(message)
// Sort messages by timestamp to ensure proper ordering
messages.sort { $0.timestamp < $1.timestamp }
+235 -22
View File
@@ -203,6 +203,30 @@ struct ContentView: View {
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
} else if let currentRoom = viewModel.currentRoom {
// Room header
HStack(spacing: 4) {
Text(currentRoom)
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(Color.orange)
Spacer()
Button(action: {
viewModel.switchToRoom(nil)
}) {
Text("main")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(textColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
} else {
// Public chat header
HStack(spacing: 4) {
@@ -269,9 +293,15 @@ struct ContentView: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 2) {
let messages = viewModel.selectedPrivateChatPeer != nil
? viewModel.getPrivateChatMessages(for: viewModel.selectedPrivateChatPeer!)
: viewModel.messages
let messages: [BitchatMessage] = {
if let privatePeer = viewModel.selectedPrivateChatPeer {
return viewModel.getPrivateChatMessages(for: privatePeer)
} else if let currentRoom = viewModel.currentRoom {
return viewModel.getRoomMessages(currentRoom)
} else {
return viewModel.messages
}
}()
ForEach(Array(messages.enumerated()), id: \.offset) { index, message in
VStack(alignment: .leading, spacing: 4) {
@@ -314,11 +344,13 @@ struct ContentView: View {
Text(" ")
// Message content
// Regular text content
Text(viewModel.formatMessageContent(message, colorScheme: colorScheme))
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
// Message content with clickable hashtags
MessageContentView(
message: message,
viewModel: viewModel,
colorScheme: colorScheme,
isMentioned: isMentioned
)
Spacer()
}
@@ -424,19 +456,91 @@ struct ContentView: View {
Divider()
// People list
// Rooms and People list
ScrollView {
VStack(alignment: .leading, spacing: 8) {
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
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: {
viewModel.switchToRoom(room)
withAnimation(.spring()) {
showSidebar = false
}
}) {
HStack {
Text(room)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(viewModel.currentRoom == room ? Color.orange : 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())
}
// 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..<range.lowerBound])
if !beforeText.isEmpty {
segments.append((beforeText, "text"))
}
}
// Add the match
let matchText = String(content[range])
segments.append((matchText, matchType))
lastEnd = range.upperBound
}
}
// Add any remaining text
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
if !remainingText.isEmpty {
segments.append((remainingText, "text"))
}
}
return segments
}
}