From 78ddb36db775059b9d8bd9b3a93b6e9e3f1df88f Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 3 Jul 2025 23:24:30 +0200 Subject: [PATCH] Add tap-to-PM and favorite features - Make sender names tappable in messages to start private chat - Add star button in private chat header to favorite/unfavorite peers - Refactor message view to separate timestamp, sender, and content - Add helper methods for formatting different parts of messages - Own sender name is not tappable (only other users) - Voice notes remain tappable to play/pause --- bitchat/Services/BluetoothMeshService.swift | 6 +- bitchat/ViewModels/ChatViewModel.swift | 91 +++++++++++++++++++++ bitchat/Views/ContentView.swift | 77 +++++++++++++---- 3 files changed, 157 insertions(+), 17 deletions(-) diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index f8c05b24..821ddd9d 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -999,7 +999,11 @@ extension BluetoothMeshService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - // Handle write completion if needed + if let error = error { + print("[PERIPHERAL] Write failed: \(error)") + } else { + print("[PERIPHERAL] Write completed successfully") + } } func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 5b8c0416..6bf521af 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -271,6 +271,97 @@ class ChatViewModel: ObservableObject { return range.location + nickname.count + 2 } + func getSenderColor(for message: BitchatMessage, colorScheme: ColorScheme) -> Color { + let isDark = colorScheme == .dark + let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + + if message.sender == nickname { + return primaryColor + } else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender), + let rssi = meshService.getPeerRSSI()[peerID] { + return getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme) + } else { + return primaryColor.opacity(0.9) + } + } + + func formatVoiceNoteContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isDark = colorScheme == .dark + let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + + var result = AttributedString() + var contentStyle = AttributeContainer() + contentStyle.font = .system(size: 14, design: .monospaced) + contentStyle.foregroundColor = isDark ? Color.white : Color.black + + // Parse the emoji and duration from content + let parts = message.content.components(separatedBy: " ") + if parts.count >= 2 { + // Emoji + result.append(AttributedString(parts[0] + " ").mergingAttributes(contentStyle)) + + // Play/pause button as text + let playSymbol = audioPlayer.isPlaying && audioPlayer.currentPlayingMessageID == message.id ? "⏸" : "▶" + var playStyle = AttributeContainer() + playStyle.font = .system(size: 12, design: .monospaced) + playStyle.foregroundColor = primaryColor + result.append(AttributedString(playSymbol + " ").mergingAttributes(playStyle)) + + // Duration + result.append(AttributedString(parts[1]).mergingAttributes(contentStyle)) + } else { + result.append(AttributedString(message.content).mergingAttributes(contentStyle)) + } + + return result + } + + func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isDark = colorScheme == .dark + 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)) ?? [] + + var lastEndIndex = contentText.startIndex + + for match in matches { + // Add text before the mention + if let range = Range(match.range(at: 0), in: contentText) { + let beforeText = String(contentText[lastEndIndex.. AttributedString { var result = AttributedString() diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 6b6464b3..efd366c7 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -221,14 +221,15 @@ struct ContentView: View { Spacer() - // Invisible spacer to balance the back button - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12)) - Text("back") - .font(.system(size: 14, design: .monospaced)) + // Favorite button + Button(action: { + viewModel.toggleFavorite(peerID: privatePeerID) + }) { + Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") + .font(.system(size: 16)) + .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) } - .opacity(0) + .buttonStyle(.plain) } else { // Public chat header HStack(spacing: 4) { @@ -359,17 +360,61 @@ struct ContentView: View { // Check if current user is mentioned let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false - Text(viewModel.formatMessage(message, colorScheme: colorScheme)) - .font(.system(size: 14, design: .monospaced)) - .fontWeight(isMentioned ? .bold : .regular) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .onTapGesture { - if message.voiceNoteData != nil { - viewModel.playVoiceNote(message: message) + if message.sender == "system" { + // System messages + Text(viewModel.formatMessage(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // Regular messages with tappable sender name + HStack(alignment: .top, spacing: 0) { + // Timestamp + Text("[\\(viewModel.formatTimestamp(message.timestamp))] ") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + // 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: 12, weight: .medium, design: .monospaced)) + .foregroundColor(senderColor) + } + .buttonStyle(.plain) + } else { + // Own messages not tappable + Text("<\\(message.sender)>") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) } + + Text(" ") + + // Message content + if message.voiceNoteData != nil { + // Voice note with play button + Text(viewModel.formatVoiceNoteContent(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) + .onTapGesture { + viewModel.playVoiceNote(message: message) + } + } else { + // Regular text content + Text(viewModel.formatMessageContent(message, colorScheme: colorScheme)) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) + } + + Spacer() } - + } } .padding(.horizontal, 12) .padding(.vertical, 2)