mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +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
|
||||
// - 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user