mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
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:
@@ -193,6 +193,7 @@ extension BitchatMessage {
|
|||||||
// - Recipient nickname length + data
|
// - Recipient nickname length + data
|
||||||
// - Sender peer ID length + data
|
// - Sender peer ID length + data
|
||||||
// - Mentions array
|
// - Mentions array
|
||||||
|
// - Room hashtag
|
||||||
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if isRelay { flags |= 0x01 }
|
if isRelay { flags |= 0x01 }
|
||||||
@@ -201,6 +202,7 @@ extension BitchatMessage {
|
|||||||
if recipientNickname != nil { flags |= 0x08 }
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
if senderPeerID != nil { flags |= 0x10 }
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
if room != nil { flags |= 0x40 }
|
||||||
|
|
||||||
data.append(flags)
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +300,7 @@ extension BitchatMessage {
|
|||||||
let hasRecipientNickname = (flags & 0x08) != 0
|
let hasRecipientNickname = (flags & 0x08) != 0
|
||||||
let hasSenderPeerID = (flags & 0x10) != 0
|
let hasSenderPeerID = (flags & 0x10) != 0
|
||||||
let hasMentions = (flags & 0x20) != 0
|
let hasMentions = (flags & 0x20) != 0
|
||||||
|
let hasRoom = (flags & 0x40) != 0
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
guard offset + 8 <= dataCopy.count else {
|
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(
|
let message = BitchatMessage(
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content: content,
|
content: content,
|
||||||
@@ -398,7 +417,8 @@ extension BitchatMessage {
|
|||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeerID: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
mentions: mentions
|
mentions: mentions,
|
||||||
|
room: room
|
||||||
)
|
)
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,8 +133,9 @@ struct BitchatMessage: Codable, Equatable {
|
|||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeerID: String?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
|
let room: String? // Room hashtag (e.g., "#general")
|
||||||
|
|
||||||
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.id = UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -145,6 +146,7 @@ struct BitchatMessage: Codable, Equatable {
|
|||||||
self.recipientNickname = recipientNickname
|
self.recipientNickname = recipientNickname
|
||||||
self.senderPeerID = senderPeerID
|
self.senderPeerID = senderPeerID
|
||||||
self.mentions = mentions
|
self.mentions = mentions
|
||||||
|
self.room = room
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ class BluetoothMeshService: NSObject {
|
|||||||
self.characteristic = characteristic
|
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
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -520,7 +520,8 @@ class BluetoothMeshService: NSObject {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions,
|
||||||
|
room: room
|
||||||
)
|
)
|
||||||
|
|
||||||
if let messageData = message.toBinaryPayload() {
|
if let messageData = message.toBinaryPayload() {
|
||||||
@@ -847,11 +848,9 @@ class BluetoothMeshService: NSObject {
|
|||||||
processedKeyExchanges = processedKeyExchanges.filter { !$0.contains(peerID) }
|
processedKeyExchanges = processedKeyExchanges.filter { !$0.contains(peerID) }
|
||||||
|
|
||||||
peerNicknamesLock.lock()
|
peerNicknamesLock.lock()
|
||||||
let nickname = peerNicknames[peerID]
|
|
||||||
peerNicknames.removeValue(forKey: peerID)
|
peerNicknames.removeValue(forKey: peerID)
|
||||||
peerNicknamesLock.unlock()
|
peerNicknamesLock.unlock()
|
||||||
|
|
||||||
let lastSeenAgo = peerLastSeenTimestamps[peerID].map { now.timeIntervalSince($0) } ?? 999
|
|
||||||
// Removed stale peer
|
// Removed stale peer
|
||||||
}
|
}
|
||||||
activePeersLock.unlock()
|
activePeersLock.unlock()
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ class ChatViewModel: ObservableObject {
|
|||||||
@Published var autocompleteRange: NSRange? = nil
|
@Published var autocompleteRange: NSRange? = nil
|
||||||
@Published var selectedAutocompleteIndex: Int = 0
|
@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()
|
let meshService = BluetoothMeshService()
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
private let favoritesKey = "bitchat.favorites"
|
private let favoritesKey = "bitchat.favorites"
|
||||||
|
private let joinedRoomsKey = "bitchat.joinedRooms"
|
||||||
private var nicknameSaveTimer: Timer?
|
private var nicknameSaveTimer: Timer?
|
||||||
|
|
||||||
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
|
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
|
||||||
@@ -48,6 +55,7 @@ class ChatViewModel: ObservableObject {
|
|||||||
init() {
|
init() {
|
||||||
loadNickname()
|
loadNickname()
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
loadJoinedRooms()
|
||||||
meshService.delegate = self
|
meshService.delegate = self
|
||||||
|
|
||||||
// Start mesh service immediately
|
// Start mesh service immediately
|
||||||
@@ -85,6 +93,79 @@ class ChatViewModel: ObservableObject {
|
|||||||
userDefaults.synchronize()
|
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) {
|
func toggleFavorite(peerID: String) {
|
||||||
// Use public key fingerprints for persistent favorites
|
// Use public key fingerprints for persistent favorites
|
||||||
guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else {
|
guard let fingerprint = peerIDToPublicKeyFingerprint[peerID] else {
|
||||||
@@ -134,8 +215,12 @@ class ChatViewModel: ObservableObject {
|
|||||||
// Send as private message
|
// Send as private message
|
||||||
sendPrivateMessage(content, to: selectedPeer)
|
sendPrivateMessage(content, to: selectedPeer)
|
||||||
} else {
|
} else {
|
||||||
// Parse mentions from the content
|
// Parse mentions and rooms from the content
|
||||||
let mentions = parseMentions(from: 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
|
// Add message to local display
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -144,12 +229,30 @@ class ChatViewModel: ObservableObject {
|
|||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
mentions: mentions.isEmpty ? nil : mentions
|
mentions: mentions.isEmpty ? nil : mentions,
|
||||||
|
room: messageRoom
|
||||||
)
|
)
|
||||||
messages.append(message)
|
|
||||||
|
|
||||||
// Send via mesh with mentions
|
if let room = messageRoom {
|
||||||
meshService.sendMessage(content, mentions: mentions)
|
// 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
|
let contentText = message.content
|
||||||
var processedContent = AttributedString()
|
var processedContent = AttributedString()
|
||||||
|
|
||||||
// Regular expression to find @mentions
|
// Regular expressions for mentions and hashtags
|
||||||
let pattern = "@([a-zA-Z0-9_]+)"
|
let mentionPattern = "@([a-zA-Z0-9_]+)"
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
||||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
||||||
|
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
|
var lastEndIndex = contentText.startIndex
|
||||||
|
|
||||||
for match in matches {
|
for (matchRange, matchType) in allMatches {
|
||||||
// Add text before the mention
|
// Add text before the match
|
||||||
if let range = Range(match.range(at: 0), in: contentText) {
|
if let range = Range(matchRange, in: contentText) {
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
if !beforeText.isEmpty {
|
if !beforeText.isEmpty {
|
||||||
var normalStyle = AttributeContainer()
|
var normalStyle = AttributeContainer()
|
||||||
@@ -367,12 +485,20 @@ class ChatViewModel: ObservableObject {
|
|||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the mention with highlight
|
// Add the match with appropriate styling
|
||||||
let mentionText = String(contentText[range])
|
let matchText = String(contentText[range])
|
||||||
var mentionStyle = AttributeContainer()
|
var matchStyle = AttributeContainer()
|
||||||
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
||||||
mentionStyle.foregroundColor = Color.orange
|
|
||||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
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
|
lastEndIndex = range.upperBound
|
||||||
}
|
}
|
||||||
@@ -517,8 +643,27 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
} else if message.sender == nickname {
|
} else if message.sender == nickname {
|
||||||
// Our own message that was echoed back - ignore it since we already added it locally
|
// 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 {
|
} else {
|
||||||
// Regular public message
|
// Regular public message (main chat)
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
// Sort messages by timestamp to ensure proper ordering
|
// Sort messages by timestamp to ensure proper ordering
|
||||||
messages.sort { $0.timestamp < $1.timestamp }
|
messages.sort { $0.timestamp < $1.timestamp }
|
||||||
|
|||||||
+235
-22
@@ -203,6 +203,30 @@ struct ContentView: View {
|
|||||||
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
.foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
} else if let currentRoom = viewModel.currentRoom {
|
||||||
|
// 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 {
|
} else {
|
||||||
// Public chat header
|
// Public chat header
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
@@ -269,9 +293,15 @@ struct ContentView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 2) {
|
LazyVStack(alignment: .leading, spacing: 2) {
|
||||||
let messages = viewModel.selectedPrivateChatPeer != nil
|
let messages: [BitchatMessage] = {
|
||||||
? viewModel.getPrivateChatMessages(for: viewModel.selectedPrivateChatPeer!)
|
if let privatePeer = viewModel.selectedPrivateChatPeer {
|
||||||
: viewModel.messages
|
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
|
ForEach(Array(messages.enumerated()), id: \.offset) { index, message in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -314,11 +344,13 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Text(" ")
|
Text(" ")
|
||||||
|
|
||||||
// Message content
|
// Message content with clickable hashtags
|
||||||
// Regular text content
|
MessageContentView(
|
||||||
Text(viewModel.formatMessageContent(message, colorScheme: colorScheme))
|
message: message,
|
||||||
.font(.system(size: 14, design: .monospaced))
|
viewModel: viewModel,
|
||||||
.fontWeight(isMentioned ? .bold : .regular)
|
colorScheme: colorScheme,
|
||||||
|
isMentioned: isMentioned
|
||||||
|
)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -424,18 +456,90 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// People list
|
// Rooms and People list
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if viewModel.connectedPeers.isEmpty {
|
// Joined Rooms section
|
||||||
Text("No one connected")
|
if !viewModel.joinedRooms.isEmpty {
|
||||||
.font(.system(size: 14, design: .monospaced))
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.foregroundColor(secondaryTextColor)
|
Text("ROOMS")
|
||||||
.padding(.horizontal)
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||||
} else {
|
.foregroundColor(secondaryTextColor)
|
||||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
.padding(.horizontal, 12)
|
||||||
let peerRSSI = viewModel.meshService.getPeerRSSI()
|
|
||||||
let myPeerID = viewModel.meshService.myPeerID
|
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
|
// Sort peers: favorites first, then alphabetically by nickname
|
||||||
let sortedPeers = viewModel.connectedPeers.filter { $0 != myPeerID }.sorted { peer1, peer2 in
|
let sortedPeers = viewModel.connectedPeers.filter { $0 != myPeerID }.sorted { peer1, peer2 in
|
||||||
@@ -502,14 +606,123 @@ struct ContentView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user