From fc5eba4a5806252ccc23163048e38a9d46828362 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 2 Jul 2025 21:14:42 +0200 Subject: [PATCH] Fix header bouncing and add private message notifications - Fix header bar height to prevent bouncing when switching views - Add in-app notification banner for incoming private messages - Show orange notification that slides down from top - Tap notification to jump directly to private chat - Use medium haptic feedback for private messages (iOS) - Auto-dismiss notifications after 3 seconds --- bitchat/ViewModels/ChatViewModel.swift | 29 ++++++- bitchat/Views/ContentView.swift | 101 ++++++++++++++++--------- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 536c58c6..98746670 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -20,11 +20,13 @@ class ChatViewModel: ObservableObject { @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages @Published var selectedPrivateChatPeer: String? = nil @Published var unreadPrivateMessages: Set = [] + @Published var privateMessageNotification: (sender: String, message: String)? = nil let meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" private var nicknameSaveTimer: Timer? + private var notificationTimer: Timer? init() { loadNickname() @@ -101,6 +103,17 @@ class ChatViewModel: ObservableObject { return nicknames.first(where: { $0.value == nickname })?.key } + private func showPrivateMessageNotification(from sender: String, content: String) { + // Show notification + privateMessageNotification = (sender: sender, message: content) + + // Auto-dismiss after 3 seconds + notificationTimer?.invalidate() + notificationTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in + self?.privateMessageNotification = nil + } + } + func formatTimestamp(_ date: Date) -> String { let formatter = DateFormatter() @@ -173,6 +186,9 @@ extension ChatViewModel: BitchatDelegate { if selectedPrivateChatPeer != peerID { unreadPrivateMessages.insert(peerID) print("[DEBUG] Added unread message indicator for peer: \(peerID)") + + // Show notification banner + showPrivateMessageNotification(from: message.sender, content: message.content) } } else if message.sender == nickname { // Our own message - find recipient by nickname @@ -190,9 +206,16 @@ extension ChatViewModel: BitchatDelegate { } #if os(iOS) - // Haptic feedback for new messages - let impactFeedback = UIImpactFeedbackGenerator(style: .light) - impactFeedback.impactOccurred() + // Different haptic feedback for private vs public messages + if message.isPrivate && message.sender != nickname { + // Medium haptic for private messages + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + } else { + // Light haptic for public messages + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + } #endif } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 54d5bd65..948e72a0 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -20,15 +20,50 @@ struct ContentView: View { } var body: some View { - VStack(spacing: 0) { - headerView - Divider() - messagesView - Divider() - inputView + ZStack { + VStack(spacing: 0) { + headerView + Divider() + messagesView + Divider() + inputView + } + .background(backgroundColor) + .foregroundColor(textColor) + + // Private message notification overlay + if let notification = viewModel.privateMessageNotification { + VStack { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Message from \(notification.sender)") + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(.white) + Text(notification.message) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(2) + } + Spacer() + } + .padding() + .background(Color.orange) + .cornerRadius(8) + .shadow(radius: 4) + .padding(.horizontal) + .onTapGesture { + if let peerID = viewModel.getPeerIDForNickname(notification.sender) { + viewModel.startPrivateChat(with: peerID) + viewModel.privateMessageNotification = nil + } + } + Spacer() + } + .padding(.top, 60) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.easeInOut, value: viewModel.privateMessageNotification) + } } - .background(backgroundColor) - .foregroundColor(textColor) #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif @@ -39,38 +74,36 @@ struct ContentView: View { if let privatePeerID = viewModel.selectedPrivateChatPeer, let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { // Private chat header - HStack { - Button(action: { - viewModel.endPrivateChat() - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12)) - Text("back") - .font(.system(size: 14, design: .monospaced)) - } - .foregroundColor(textColor) - } - .buttonStyle(.plain) - - Spacer() - - Text("private: \(privatePeerNick)") - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(Color.orange) - .frame(maxWidth: .infinity) - - Spacer() - - // Invisible spacer to balance the back button + Button(action: { + viewModel.endPrivateChat() + }) { HStack(spacing: 4) { Image(systemName: "chevron.left") .font(.system(size: 12)) Text("back") .font(.system(size: 14, design: .monospaced)) } - .opacity(0) + .foregroundColor(textColor) } + .buttonStyle(.plain) + + Spacer() + + Text("private: \(privatePeerNick)") + .font(.system(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(Color.orange) + .frame(maxWidth: .infinity) + + 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)) + } + .opacity(0) } else { // Public chat header Text("bitchat") @@ -103,8 +136,8 @@ struct ContentView: View { } } } + .frame(height: 44) // Fixed height to prevent bouncing .padding(.horizontal, 12) - .padding(.vertical, 8) .background(backgroundColor.opacity(0.95)) }