Фикс: пуш-уведомления — убраны кастомные in-app баннеры, Desktop-active suppression, NSE timeout safety

This commit is contained in:
2026-04-07 22:26:30 +05:00
parent 62c24d19cf
commit 168abb8aec
10 changed files with 101 additions and 278 deletions

View File

@@ -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
}
}

View File

@@ -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 секунд.
"""
)
]

View File

@@ -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() }
}
}

View File

@@ -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()

View File

@@ -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.