Фикс: пуш-аватарки (Communication Notification entitlement) + in-app баннер 1:1 Telegram parity

This commit is contained in:
2026-04-08 01:03:13 +05:00
parent bde2e78f3d
commit f6af59ba11
6 changed files with 67 additions and 25 deletions

View File

@@ -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 = 34; CURRENT_PROJECT_VERSION = 35;
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.3; MARKETING_VERSION = 1.3.4;
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 = 34; CURRENT_PROJECT_VERSION = 35;
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.3; MARKETING_VERSION = 1.3.4;
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 = "";

View File

@@ -13,7 +13,7 @@ enum ReleaseNotes {
body: """ body: """
**Пуш-уведомления** **Пуш-уведомления**
In-app баннеры (Telegram parity) — при получении сообщения внутри приложения. Исправлен спам вибраций при входе. Аватарки в пушах совпадают с приложением. Группы без фото — иконка людей. Desktop-suppression 30 сек. Аватарки отправителей в системных пушах (Communication Notification). In-app баннер переделан 1-в-1 как в Telegram (glass-фон, жесты, анимации). Исправлен спам вибраций при входе. Desktop-suppression 30 сек.
""" """
) )
] ]

View File

@@ -5,13 +5,13 @@ import SwiftUI
/// Telegram-style in-app notification banner shown when a message arrives /// Telegram-style in-app notification banner shown when a message arrives
/// while the app is in foreground and the user is NOT in that chat. /// while the app is in foreground and the user is NOT in that chat.
/// ///
/// Specs (from Telegram iOS `ChatMessageNotificationItem.swift`): /// Specs (from Telegram iOS `ChatMessageNotificationItem.swift` + `NotificationItemContainerNode.swift`):
/// - Panel: 74pt height, 24pt corner radius, 8pt horizontal margin /// - Panel: 74pt min height, 24pt corner radius, 8pt horizontal margin
/// - Avatar: 54pt circle, 12pt left inset /// - Avatar: 54pt circle, 12pt left inset, 23pt avatar-to-text spacing
/// - Title: semibold 16pt, white, 1 line /// - Title: semibold 16pt, white, 1 line
/// - Message: regular 16pt, white 70% opacity, max 2 lines /// - Message: regular 16pt, white (full), max 2 lines
/// - Background: ultraThinMaterial (dark) /// - Background: TelegramGlass (CABackdropLayer / UIGlassEffect)
/// - Slide from top: 0.4s, auto-dismiss: 5s, swipe-up dismiss /// - Slide from top: 0.4s, auto-dismiss: 5s, swipe dismiss with 0.55 damping
struct InAppBannerView: View { struct InAppBannerView: View {
let senderName: String let senderName: String
let messagePreview: String let messagePreview: String
@@ -28,7 +28,7 @@ struct InAppBannerView: View {
private let horizontalMargin: CGFloat = 8 private let horizontalMargin: CGFloat = 8
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 23) {
avatarView avatarView
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(senderName) Text(senderName)
@@ -37,36 +37,44 @@ struct InAppBannerView: View {
.lineLimit(1) .lineLimit(1)
Text(messagePreview) Text(messagePreview)
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white)
.lineLimit(2) .lineLimit(2)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.padding(.horizontal, 12) .padding(.leading, 12)
.frame(height: panelHeight) .padding(.trailing, 10)
.frame(minHeight: panelHeight)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) .glass(cornerRadius: cornerRadius)
.padding(.horizontal, horizontalMargin) .padding(.horizontal, horizontalMargin)
.offset(y: dragOffset) .offset(y: dragOffset)
.gesture( .gesture(
DragGesture(minimumDistance: 5) DragGesture(minimumDistance: 5)
.onChanged { value in .onChanged { value in
// Only allow upward drag (negative Y). let translation = value.translation.height
if value.translation.height < 0 { if translation < 0 {
dragOffset = value.translation.height // Dragging up full 1:1 tracking.
dragOffset = translation
} else {
// Dragging down logarithmic rubber-band (Telegram: 0.55/50 cap).
// Formula: -((1 - 1/((delta*0.55/50)+1)) * 50)
let delta = translation
dragOffset = (1.0 - (1.0 / ((delta * 0.55 / 50.0) + 1.0))) * 50.0
} }
} }
.onEnded { value in .onEnded { value in
// Dismiss if dragged up > 20pt or velocity > 300. let velocity = value.predictedEndTranslation.height - value.translation.height
if value.translation.height < -20 // Dismiss: swiped up > 20pt or fast upward velocity (Telegram: -20pt / 300pt/s).
|| value.predictedEndTranslation.height < -100 { if value.translation.height < -20 || velocity < -300 {
withAnimation(.easeOut(duration: 0.3)) { withAnimation(.easeOut(duration: 0.4)) {
dragOffset = -200 dragOffset = -200
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
onDismiss() onDismiss()
} }
} else { } else {
// Spring back (Telegram: 0.3s easeInOut).
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
dragOffset = 0 dragOffset = 0
} }

View File

@@ -850,7 +850,8 @@ struct RosettaApp: App {
@ViewBuilder @ViewBuilder
private var inAppBannerOverlay: some View { private var inAppBannerOverlay: some View {
VStack { // Telegram parity: 8pt inset below safe area top (NotificationItemContainerNode.swift:98).
VStack(spacing: 0) {
if let banner = bannerManager.currentBanner { if let banner = bannerManager.currentBanner {
InAppBannerView( InAppBannerView(
senderName: banner.senderName, senderName: banner.senderName,

View File

@@ -497,6 +497,9 @@ final class NotificationService: UNNotificationServiceExtension {
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
guard let pngData = image?.pngData() else { return nil } guard let pngData = image?.pngData() else { return nil }
if let tempURL = storeTemporaryImage(data: pngData, key: "letter-\(key)", fileExtension: "png") {
return INImage(url: tempURL)
}
return INImage(imageData: pngData) return INImage(imageData: pngData)
} }
@@ -532,6 +535,9 @@ final class NotificationService: UNNotificationServiceExtension {
UIGraphicsEndImageContext() UIGraphicsEndImageContext()
guard let pngData = image?.pngData() else { return nil } guard let pngData = image?.pngData() else { return nil }
if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") {
return INImage(url: tempURL)
}
return INImage(imageData: pngData) return INImage(imageData: pngData)
} }
@@ -550,6 +556,9 @@ final class NotificationService: UNNotificationServiceExtension {
guard !normalized.isEmpty else { continue } guard !normalized.isEmpty else { continue }
let avatarURL = avatarsDir.appendingPathComponent("\(normalized).jpg") let avatarURL = avatarsDir.appendingPathComponent("\(normalized).jpg")
if let data = try? Data(contentsOf: avatarURL), !data.isEmpty { if let data = try? Data(contentsOf: avatarURL), !data.isEmpty {
if let tempURL = storeTemporaryImage(data: data, key: "photo-\(normalized)", fileExtension: "jpg") {
return INImage(url: tempURL)
}
return INImage(imageData: data) return INImage(imageData: data)
} }
} }
@@ -593,6 +602,28 @@ final class NotificationService: UNNotificationServiceExtension {
.lowercased() .lowercased()
} }
/// Writes image data to NSTemporaryDirectory so INImage can reference it via file URL.
/// Telegram approach: INImage(imageData:) is unreliable in NSE file URL works.
private static func storeTemporaryImage(data: Data, key: String, fileExtension: String) -> URL? {
let imagesPath = NSTemporaryDirectory() + "aps-data"
try? FileManager.default.createDirectory(
at: URL(fileURLWithPath: imagesPath),
withIntermediateDirectories: true
)
let fileName = key.replacingOccurrences(of: "/", with: "_")
let tempURL = URL(fileURLWithPath: imagesPath)
.appendingPathComponent("\(fileName).\(fileExtension)")
if FileManager.default.fileExists(atPath: tempURL.path) {
return tempURL
}
do {
try data.write(to: tempURL, options: [.atomic])
return tempURL
} catch {
return nil
}
}
// MARK: - Helpers // MARK: - Helpers
/// Android parity: extract sender key from multiple possible key names. /// Android parity: extract sender key from multiple possible key names.

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.rosetta.dev</string> <string>group.com.rosetta.dev</string>