Фикс: пуш-аватарки (Communication Notification entitlement) + in-app баннер 1:1 Telegram parity
This commit is contained in:
@@ -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 = "";
|
||||||
|
|||||||
@@ -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 сек.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user