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
This commit is contained in:
jack
2025-07-02 21:14:42 +02:00
parent 5c9d2122dc
commit fc5eba4a58
2 changed files with 93 additions and 37 deletions
+26 -3
View File
@@ -20,11 +20,13 @@ class ChatViewModel: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
@Published var selectedPrivateChatPeer: String? = nil
@Published var unreadPrivateMessages: Set<String> = []
@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
}
+67 -34
View File
@@ -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))
}