Фикс: пуш-уведомления — убраны кастомные in-app баннеры, Desktop-active suppression, NSE timeout safety
This commit is contained in:
@@ -1,93 +1,10 @@
|
||||
import AudioToolbox
|
||||
import AVFAudio
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
/// Manages in-app notification banners (Telegram parity).
|
||||
/// Shows custom overlay instead of system banners when app is in foreground.
|
||||
@MainActor
|
||||
final class InAppNotificationManager: ObservableObject {
|
||||
/// Foreground notification suppression logic.
|
||||
/// Determines whether a notification should be silenced (active chat or muted).
|
||||
enum InAppNotificationManager {
|
||||
|
||||
static let shared = InAppNotificationManager()
|
||||
|
||||
@Published private(set) var currentNotification: InAppNotification?
|
||||
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var soundPlayer: AVAudioPlayer?
|
||||
|
||||
private static let autoDismissSeconds: UInt64 = 5
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Data Model
|
||||
|
||||
struct InAppNotification: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let senderKey: String
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
let avatar: UIImage?
|
||||
let avatarColorIndex: Int
|
||||
let initials: String
|
||||
|
||||
static func == (lhs: InAppNotification, rhs: InAppNotification) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Called from `willPresent` — extracts sender info and shows banner if appropriate.
|
||||
func show(userInfo: [AnyHashable: Any]) {
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? AppDelegate.extractSenderKey(from: userInfo)
|
||||
|
||||
// --- Suppression logic (Telegram parity) ---
|
||||
guard !senderKey.isEmpty else { return }
|
||||
guard !MessageRepository.shared.isDialogActive(senderKey) else { return }
|
||||
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
guard !mutedKeys.contains(senderKey) else { return }
|
||||
|
||||
// --- Resolve display data ---
|
||||
let contactNames = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
||||
let name = contactNames[senderKey]
|
||||
?? firstString(userInfo, keys: ["title", "sender_name", "from_title", "name"])
|
||||
?? "Rosetta"
|
||||
|
||||
let preview = extractMessagePreview(from: userInfo)
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: senderKey)
|
||||
let dialog = DialogRepository.shared.dialogs[senderKey]
|
||||
|
||||
let notification = InAppNotification(
|
||||
id: UUID(),
|
||||
senderKey: senderKey,
|
||||
senderName: name,
|
||||
messagePreview: preview,
|
||||
avatar: avatar,
|
||||
avatarColorIndex: dialog?.avatarColorIndex ?? abs(senderKey.hashValue) % 11,
|
||||
initials: dialog?.initials ?? String(name.prefix(1)).uppercased()
|
||||
)
|
||||
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
currentNotification = notification
|
||||
}
|
||||
|
||||
playNotificationSound()
|
||||
playHaptic()
|
||||
scheduleAutoDismiss()
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
dismissTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
currentNotification = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable: checks whether a notification should be suppressed.
|
||||
/// Returns `true` if the notification should be suppressed (no banner).
|
||||
static func shouldSuppress(senderKey: String) -> Bool {
|
||||
if senderKey.isEmpty { return true }
|
||||
if MessageRepository.shared.isDialogActive(senderKey) { return true }
|
||||
@@ -96,48 +13,4 @@ final class InAppNotificationManager: ObservableObject {
|
||||
if mutedKeys.contains(senderKey) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleAutoDismiss() {
|
||||
dismissTask?.cancel()
|
||||
dismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: Self.autoDismissSeconds * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func playNotificationSound() {
|
||||
// System "Tink" haptic feedback sound — lightweight, no custom mp3 needed.
|
||||
AudioServicesPlaySystemSound(1057)
|
||||
}
|
||||
|
||||
private func playHaptic() {
|
||||
// UIImpactFeedbackGenerator style tap via AudioServices.
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
private func extractMessagePreview(from userInfo: [AnyHashable: Any]) -> String {
|
||||
// Try notification body directly.
|
||||
if let body = userInfo["body"] as? String, !body.isEmpty { return body }
|
||||
// Try aps.alert (can be string or dict).
|
||||
if let aps = userInfo["aps"] as? [String: Any] {
|
||||
if let alert = aps["alert"] as? String, !alert.isEmpty { return alert }
|
||||
if let alertDict = aps["alert"] as? [String: Any],
|
||||
let body = alertDict["body"] as? String, !body.isEmpty { return body }
|
||||
}
|
||||
return "New message"
|
||||
}
|
||||
|
||||
private func firstString(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String,
|
||||
!value.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,8 @@ enum ReleaseNotes {
|
||||
version: appVersion,
|
||||
body: """
|
||||
|
||||
**Пуш-уведомления — Telegram-parity и стабильность**
|
||||
Группировка по чатам (threadIdentifier). Фикс исчезновения части уведомлений при тапе по пушу. NSE фильтрует повторные уведомления от одного отправителя и использует приоритет реальной аватарки из App Group (fallback: letter-avatar).
|
||||
|
||||
**Дедупликация и валидация протокола**
|
||||
Трёхуровневая защита от дублей (queue + process + DB). Улучшена валидация входящих пакетов для защиты от некорректных данных при синхронизации. Forward Picker UI parity.
|
||||
**Пуш-уведомления**
|
||||
Только системные баннеры iOS — убраны кастомные in-app оверлеи, звуки и вибрации. Desktop-suppression: если читаешь на компьютере, телефон молчит 30 секунд.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Telegram-style in-app notification banner with glass background.
|
||||
/// Shows sender avatar, name, and message preview. Supports swipe-up
|
||||
/// to dismiss and tap to navigate to the chat.
|
||||
struct InAppNotificationBanner: View {
|
||||
|
||||
let notification: InAppNotificationManager.InAppNotification
|
||||
let onTap: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: notification.initials,
|
||||
colorIndex: notification.avatarColorIndex,
|
||||
size: 40,
|
||||
image: notification.avatar
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notification.senderName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Color(RosettaColors.Adaptive.text))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(notification.messagePreview)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(RosettaColors.Adaptive.textSecondary))
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background { TelegramGlassRoundedRect(cornerRadius: 16) }
|
||||
.padding(.horizontal, 8)
|
||||
.offset(y: min(dragOffset, 0))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
// Only allow dragging upward (negative Y).
|
||||
dragOffset = min(value.translation.height, 0)
|
||||
}
|
||||
.onEnded { value in
|
||||
if value.translation.height < -30 {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ struct MainTabView: View {
|
||||
@State private var isSettingsEditPresented = false
|
||||
@State private var isSettingsDetailPresented = false
|
||||
@StateObject private var callManager = CallManager.shared
|
||||
@StateObject private var notificationManager = InAppNotificationManager.shared
|
||||
|
||||
// Add Account — presented as fullScreenCover so Settings stays alive.
|
||||
// Using optional AuthScreen as the item ensures the correct screen is
|
||||
@@ -71,39 +70,6 @@ struct MainTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// In-app notification banner overlay (Telegram parity).
|
||||
// Slides from top, auto-dismisses after 5s, tap navigates to chat.
|
||||
Group {
|
||||
if let notification = notificationManager.currentNotification {
|
||||
VStack {
|
||||
InAppNotificationBanner(
|
||||
notification: notification,
|
||||
onTap: {
|
||||
let route = ChatRoute(
|
||||
publicKey: notification.senderKey,
|
||||
title: notification.senderName,
|
||||
username: "",
|
||||
verified: 0
|
||||
)
|
||||
notificationManager.dismiss()
|
||||
if selectedTab != .chats {
|
||||
selectedTab = .chats
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
)
|
||||
},
|
||||
onDismiss: { notificationManager.dismiss() }
|
||||
)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.zIndex(9)
|
||||
|
||||
// Full-screen device verification overlay (observation-isolated).
|
||||
// Covers nav bar, search bar, and tab bar — desktop parity.
|
||||
DeviceConfirmOverlay()
|
||||
|
||||
@@ -273,6 +273,21 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
dialogKey = String(dialogKey.dropFirst("#group:".count))
|
||||
}
|
||||
|
||||
// Desktop-active suppression: mark this dialog as "recently read on another device".
|
||||
// NSE checks this flag — if a new message arrives for the same dialog within 30s,
|
||||
// it suppresses the notification (user is actively reading on Desktop).
|
||||
// NOTE: When server sends mutable-content:1 for READ, NSE also writes this flag.
|
||||
// Both writes are idempotent (same dialogKey → same timestamp). Badge decrement
|
||||
// is safe: NSE removes notifications first, AppDelegate finds 0 remaining → no double-decrement.
|
||||
if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") {
|
||||
let now = Date().timeIntervalSince1970
|
||||
var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:]
|
||||
recentlyRead[dialogKey] = now
|
||||
// Evict stale entries (> 60s) to prevent unbounded growth.
|
||||
recentlyRead = recentlyRead.filter { now - $0.value < 60 }
|
||||
shared.set(recentlyRead, forKey: "nse_recently_read_dialogs")
|
||||
}
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let idsToRemove = delivered
|
||||
@@ -474,8 +489,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Handle foreground notifications — always suppress system banner,
|
||||
/// show custom in-app overlay instead (Telegram parity).
|
||||
/// Handle foreground notifications — show system banner unless chat is active or muted.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
@@ -483,14 +497,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
Void
|
||||
) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: userInfo)
|
||||
|
||||
// Always suppress system banner — custom in-app overlay handles display.
|
||||
completionHandler([])
|
||||
|
||||
// Trigger in-app notification banner (suppression logic inside manager).
|
||||
Task { @MainActor in
|
||||
InAppNotificationManager.shared.show(userInfo: userInfo)
|
||||
if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
|
||||
/// Determines whether a foreground notification should be suppressed.
|
||||
@@ -501,14 +515,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? extractSenderKey(from: userInfo)
|
||||
|
||||
// Always suppress system banner — custom in-app overlay handles display.
|
||||
// InAppNotificationManager.shouldSuppress() has the full suppression logic.
|
||||
if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Even for non-suppressed notifications, return [] — we show our own banner.
|
||||
return []
|
||||
return [.banner, .sound]
|
||||
}
|
||||
|
||||
/// Handle notification tap — navigate to the sender's chat or expand call.
|
||||
|
||||
Reference in New Issue
Block a user