Фикс: пуш-уведомления — убраны кастомные 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

@@ -7,7 +7,6 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
A1B2C3D4E5F60718293A4B5C /* DeliveryReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */; };
3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; }; 3146EDCE68162995CB5D1034 /* BehaviorParityFixtureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */; };
3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; }; 3C4D5E6F708192A3B4C5D6E7 /* AttachmentParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */; };
4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; }; 4C9BDB443750F7003CFB705C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 272B862BE4D99E7DD751CC3E /* Foundation.framework */; };
@@ -16,12 +15,9 @@
853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; };
853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; };
85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; }; 85E887F72F6DC9460032774C /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D1DB00022F8C00010092AD05 /* GRDB */; };
A1B2C3D4E5F60718293A4B5C /* DeliveryReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */; };
B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */; }; B7F1C2D34A5E67890ABCDEF1 /* CryptoParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */; };
C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */; }; C8E2D3F45B6A78901BCDEF12 /* MessageDecodeHardeningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAF1B2C3D4E5F60718293A4B /* MessageDecodeHardeningTests.swift */; };
F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */; };
F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */; };
F0B1C2D3E4F5061728394A43 /* ForegroundNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */; };
F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */; };
CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; }; CC5AD9236E3B3BA95A0C29EC /* DBTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */; };
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; }; D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; };
@@ -29,6 +25,10 @@
E20000032F8D11110092AD05 /* WebRTC.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; }; E20000032F8D11110092AD05 /* WebRTC.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; };
E20000062F8D11110092AD05 /* WebRTC.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E20000062F8D11110092AD05 /* WebRTC.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E20000042F8D11110092AD05 /* WebRTC.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; }; EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; };
F0B1C2D3E4F5061728394A41 /* PendingChatRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A41 /* PendingChatRouteTests.swift */; };
F0B1C2D3E4F5061728394A42 /* PushNotificationPacketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A42 /* PushNotificationPacketTests.swift */; };
F0B1C2D3E4F5061728394A43 /* ForegroundNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A43 /* ForegroundNotificationTests.swift */; };
F0B1C2D3E4F5061728394A44 /* PushNotificationAuditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A1B2C3D4E5F60718293A44 /* PushNotificationAuditTests.swift */; };
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
@@ -99,7 +99,6 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeliveryReliabilityTests.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; }; 1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; };
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; }; 2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
@@ -109,6 +108,7 @@
853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; }; 853F29622F4B50410092AD05 /* Rosetta.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rosetta.app; sourceTree = BUILT_PRODUCTS_DIR; };
93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 93685A4F330DCD1B63EF121F /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D4E5F60718293A4B5D /* DeliveryReliabilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeliveryReliabilityTests.swift; sourceTree = "<group>"; };
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = "<group>"; }; C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = "<group>"; };
D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CryptoParityTests.swift; sourceTree = "<group>"; }; D9A1B2C3D4E5F60718293A4B /* CryptoParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CryptoParityTests.swift; sourceTree = "<group>"; };
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; }; DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; };
@@ -641,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -657,7 +657,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.1; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -681,7 +681,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -697,7 +697,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.1; MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -841,7 +841,7 @@
C19929D9466573F31997B2C0 /* Release */, C19929D9466573F31997B2C0 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Release;
}; };
853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = { 853F295D2F4B50410092AD05 /* Build configuration list for PBXProject "Rosetta" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -850,7 +850,7 @@
853F296C2F4B50420092AD05 /* Release */, 853F296C2F4B50420092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Release;
}; };
853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = { 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -859,7 +859,7 @@
853F296F2F4B50420092AD05 /* Release */, 853F296F2F4B50420092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Release;
}; };
B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -868,7 +868,7 @@
0140D6320A9CF4B5E933E0B1 /* Debug */, 0140D6320A9CF4B5E933E0B1 /* Debug */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Release;
}; };
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -877,7 +877,7 @@
LA00000082F8D22220092AD05 /* Release */, LA00000082F8D22220092AD05 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */

View File

@@ -66,7 +66,7 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"

View File

@@ -1,93 +1,10 @@
import AudioToolbox import Foundation
import AVFAudio
import Combine
import SwiftUI
/// Manages in-app notification banners (Telegram parity). /// Foreground notification suppression logic.
/// Shows custom overlay instead of system banners when app is in foreground. /// Determines whether a notification should be silenced (active chat or muted).
@MainActor enum InAppNotificationManager {
final class InAppNotificationManager: ObservableObject {
static let shared = InAppNotificationManager() /// Returns `true` if the notification should be suppressed (no banner).
@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.
static func shouldSuppress(senderKey: String) -> Bool { static func shouldSuppress(senderKey: String) -> Bool {
if senderKey.isEmpty { return true } if senderKey.isEmpty { return true }
if MessageRepository.shared.isDialogActive(senderKey) { return true } if MessageRepository.shared.isDialogActive(senderKey) { return true }
@@ -96,48 +13,4 @@ final class InAppNotificationManager: ObservableObject {
if mutedKeys.contains(senderKey) { return true } if mutedKeys.contains(senderKey) { return true }
return false 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, version: appVersion,
body: """ body: """
**Пуш-уведомления — Telegram-parity и стабильность** **Пуш-уведомления**
Группировка по чатам (threadIdentifier). Фикс исчезновения части уведомлений при тапе по пушу. NSE фильтрует повторные уведомления от одного отправителя и использует приоритет реальной аватарки из App Group (fallback: letter-avatar). Только системные баннеры iOS — убраны кастомные in-app оверлеи, звуки и вибрации. Desktop-suppression: если читаешь на компьютере, телефон молчит 30 секунд.
**Дедупликация и валидация протокола**
Трёхуровневая защита от дублей (queue + process + DB). Улучшена валидация входящих пакетов для защиты от некорректных данных при синхронизации. Forward Picker UI parity.
""" """
) )
] ]

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 isSettingsEditPresented = false
@State private var isSettingsDetailPresented = false @State private var isSettingsDetailPresented = false
@StateObject private var callManager = CallManager.shared @StateObject private var callManager = CallManager.shared
@StateObject private var notificationManager = InAppNotificationManager.shared
// Add Account presented as fullScreenCover so Settings stays alive. // Add Account presented as fullScreenCover so Settings stays alive.
// Using optional AuthScreen as the item ensures the correct screen is // 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). // Full-screen device verification overlay (observation-isolated).
// Covers nav bar, search bar, and tab bar desktop parity. // Covers nav bar, search bar, and tab bar desktop parity.
DeviceConfirmOverlay() DeviceConfirmOverlay()

View File

@@ -273,6 +273,21 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
dialogKey = String(dialogKey.dropFirst("#group:".count)) 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() let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in center.getDeliveredNotifications { delivered in
let idsToRemove = delivered let idsToRemove = delivered
@@ -474,8 +489,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
// MARK: - UNUserNotificationCenterDelegate // MARK: - UNUserNotificationCenterDelegate
/// Handle foreground notifications always suppress system banner, /// Handle foreground notifications show system banner unless chat is active or muted.
/// show custom in-app overlay instead (Telegram parity).
func userNotificationCenter( func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
@@ -483,14 +497,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
Void Void
) { ) {
let userInfo = notification.request.content.userInfo 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. if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
completionHandler([]) completionHandler([])
return
// Trigger in-app notification banner (suppression logic inside manager).
Task { @MainActor in
InAppNotificationManager.shared.show(userInfo: userInfo)
} }
completionHandler([.banner, .sound])
} }
/// Determines whether a foreground notification should be suppressed. /// Determines whether a foreground notification should be suppressed.
@@ -501,14 +515,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let senderKey = userInfo["dialog"] as? String let senderKey = userInfo["dialog"] as? String
?? extractSenderKey(from: userInfo) ?? 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) { if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
return [] return []
} }
return [.banner, .sound]
// Even for non-suppressed notifications, return [] we show our own banner.
return []
} }
/// Handle notification tap navigate to the sender's chat or expand call. /// Handle notification tap navigate to the sender's chat or expand call.

View File

@@ -260,7 +260,15 @@ final class NotificationService: UNNotificationServiceExtension {
override func serviceExtensionTimeWillExpire() { override func serviceExtensionTimeWillExpire() {
if let handler = contentHandler, let content = bestAttemptContent { if let handler = contentHandler, let content = bestAttemptContent {
content.sound = .default // Read pushes must stay silent even on timeout no sound, no alert.
let pushType = content.userInfo["type"] as? String ?? ""
if pushType == "read" {
content.sound = nil
content.title = ""
content.body = ""
} else {
content.sound = .default
}
handler(content) handler(content)
} }
} }

View File

@@ -4,10 +4,8 @@ import UserNotifications
// MARK: - Foreground Notification Suppression Tests // MARK: - Foreground Notification Suppression Tests
/// Tests for the in-app notification banner suppression logic (Telegram parity). /// Tests for foreground notification suppression logic.
/// System banners are always suppressed (`willPresent` returns `[]`). /// `willPresent` returns `[.banner, .sound]` by default, `[]` for active/muted chats.
/// `InAppNotificationManager.shouldSuppress()` decides whether the
/// custom in-app banner should be shown or hidden.
@MainActor @MainActor
struct ForegroundNotificationTests { struct ForegroundNotificationTests {
@@ -17,21 +15,23 @@ struct ForegroundNotificationTests {
} }
} }
// MARK: - System Banner Always Suppressed // MARK: - System Banner Presentation
@Test("System banner always suppressed — foregroundPresentationOptions returns []") @Test("Non-suppressed chat shows system banner with sound")
func systemBannerAlwaysSuppressed() { func nonSuppressedShowsBanner() {
clearActiveDialogs() clearActiveDialogs()
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"] let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"]
let options = AppDelegate.foregroundPresentationOptions(for: userInfo) let options = AppDelegate.foregroundPresentationOptions(for: userInfo)
#expect(options == []) #expect(options == [.banner, .sound])
} }
@Test("System banner suppressed even for inactive chats") @Test("Active chat suppresses system banner")
func systemBannerSuppressedInactive() { func activeChatSuppressesBanner() {
clearActiveDialogs() clearActiveDialogs()
MessageRepository.shared.setDialogActive("02bbb", isActive: true)
let userInfo: [AnyHashable: Any] = ["dialog": "02bbb"] let userInfo: [AnyHashable: Any] = ["dialog": "02bbb"]
#expect(AppDelegate.foregroundPresentationOptions(for: userInfo) == []) #expect(AppDelegate.foregroundPresentationOptions(for: userInfo) == [])
MessageRepository.shared.setDialogActive("02bbb", isActive: false)
} }
// MARK: - In-App Banner: Active Chat Suppress // MARK: - In-App Banner: Active Chat Suppress

View File

@@ -297,6 +297,39 @@ struct PushNotificationDesktopSuppressionTests {
let shouldSuppress = recentlyRead[senderKey].map { now - $0 < Self.recentlyReadWindow } ?? false let shouldSuppress = recentlyRead[senderKey].map { now - $0 < Self.recentlyReadWindow } ?? false
#expect(shouldSuppress == false) #expect(shouldSuppress == false)
} }
// MARK: - AppDelegate App Group flag (READ push writes nse_recently_read_dialogs)
@Test("handleReadPush stores recently-read flag in App Group for NSE")
func readPushStoresRecentlyReadFlagInAppGroup() {
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
let key = "nse_recently_read_dialogs"
let originalData = shared?.dictionary(forKey: key)
// Simulate what handleReadPush now does: write the recently-read flag.
let dialogKey = "02test_desktop_read_flag"
let now = Date().timeIntervalSince1970
var recentlyRead = shared?.dictionary(forKey: key) as? [String: Double] ?? [:]
recentlyRead[dialogKey] = now
recentlyRead = recentlyRead.filter { now - $0.value < 60 }
shared?.set(recentlyRead, forKey: key)
// Verify flag exists and is recent.
let stored = shared?.dictionary(forKey: key) as? [String: Double] ?? [:]
#expect(stored[dialogKey] != nil)
if let ts = stored[dialogKey] {
#expect(abs(ts - now) < 2)
}
// Verify NSE suppression logic would fire for this dialog.
if let lastReadTime = stored[dialogKey] {
let elapsed = now - lastReadTime
#expect(elapsed < Self.recentlyReadWindow)
}
// Cleanup.
shared?.set(originalData, forKey: key)
}
} }
// MARK: - Read Push Group Key Normalization Tests // MARK: - Read Push Group Key Normalization Tests
@@ -657,13 +690,11 @@ struct PushInAppBannerSuppressionExtendedTests {
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false) #expect(InAppNotificationManager.shouldSuppress(senderKey: "02normal") == false)
} }
@Test("System presentation options always return empty set") @Test("System presentation returns banner+sound for non-suppressed chats")
func systemPresentationAlwaysEmpty() { func systemPresentationShowsBanner() {
clearState() clearState()
// Even for non-suppressed chats, system banner is always suppressed
// (custom in-app banner shown instead)
let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"]) let options = AppDelegate.foregroundPresentationOptions(for: ["dialog": "02any_user"])
#expect(options == []) #expect(options == [.banner, .sound])
} }
} }