Фикс: пуш-уведомления — in-app баннер (Telegram parity), аватарки Mantine, группы person.2.fill, антиспам вибраций

This commit is contained in:
2026-04-08 00:21:46 +05:00
parent 168abb8aec
commit bde2e78f3d
8 changed files with 459 additions and 64 deletions

View File

@@ -240,10 +240,12 @@ final class NotificationService: UNNotificationServiceExtension {
let senderName = resolvedName
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
?? content.title
let isGroup = pushType == "group_message"
let finalContent = Self.makeCommunicationNotification(
content: content,
senderName: senderName,
senderKey: senderKey
senderKey: senderKey,
isGroup: isGroup
)
// Android parity: for duplicate bursts, keep only the latest notification
@@ -341,12 +343,20 @@ final class NotificationService: UNNotificationServiceExtension {
private static func makeCommunicationNotification(
content: UNMutableNotificationContent,
senderName: String,
senderKey: String
senderKey: String,
isGroup: Bool = false
) -> UNNotificationContent {
let handle = INPersonHandle(value: senderKey, type: .unknown)
let displayName = senderName.isEmpty ? "Rosetta" : senderName
let avatarImage = loadNotificationAvatar(for: senderKey)
?? generateLetterAvatar(name: displayName, key: senderKey)
let avatarImage: INImage? = {
if let cached = loadNotificationAvatar(for: senderKey) {
return cached
}
if isGroup {
return generateGroupAvatar(name: displayName, key: senderKey)
}
return generateLetterAvatar(name: displayName, key: senderKey)
}()
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
@@ -360,7 +370,7 @@ final class NotificationService: UNNotificationServiceExtension {
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: nil,
speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: displayName) : nil,
conversationIdentifier: senderKey,
serviceName: "Rosetta",
sender: sender,
@@ -389,60 +399,134 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
// MARK: - Letter Avatar (Telegram parity: colored circle with initials)
// MARK: - Avatar Generation (Mantine v8 parity with main app)
/// Mantine avatar color palette matches AvatarView in main app.
private static let avatarColors: [(bg: UInt32, text: UInt32)] = [
(0x4C6EF5, 0xDBE4FF), // indigo
(0x7950F2, 0xE5DBFF), // violet
(0xF06595, 0xFFDEEB), // pink
(0xFF6B6B, 0xFFE3E3), // red
(0xFD7E14, 0xFFE8CC), // orange
(0xFAB005, 0xFFF3BF), // yellow
(0x40C057, 0xD3F9D8), // green
(0x12B886, 0xC3FAE8), // teal
(0x15AABF, 0xC5F6FA), // cyan
(0x228BE6, 0xD0EBFF), // blue
(0xBE4BDB, 0xF3D9FA), // grape
/// Mantine v8 avatar palette exact copy from Colors.swift:135-147.
/// tint = shade-6 (circle fill for groups, 15% overlay for personal)
/// text = shade-3 (dark mode initials color)
private static let avatarColors: [(tint: UInt32, text: UInt32)] = [
(0x228be6, 0x74c0fc), // blue
(0x15aabf, 0x66d9e8), // cyan
(0xbe4bdb, 0xe599f7), // grape
(0x40c057, 0x8ce99a), // green
(0x4c6ef5, 0x91a7ff), // indigo
(0x82c91e, 0xc0eb75), // lime
(0xfd7e14, 0xffc078), // orange
(0xe64980, 0xfaa2c1), // pink
(0xfa5252, 0xffa8a8), // red
(0x12b886, 0x63e6be), // teal
(0x7950f2, 0xb197fc), // violet
]
/// Generates a 50x50 circular letter avatar as INImage for notification display.
/// Mantine dark body background (#1A1B1E) matches AvatarView.swift.
private static let mantineDarkBody: UInt32 = 0x1A1B1E
/// Desktop parity: deterministic hash based on display name.
/// Exact copy of RosettaColors.avatarColorIndex(for:publicKey:) from Colors.swift:151-164.
private static func avatarColorIndex(for name: String, publicKey: String = "") -> Int {
let trimmed = name.trimmingCharacters(in: .whitespaces)
let input = trimmed.isEmpty ? String(publicKey.prefix(7)) : trimmed
var hash: Int32 = 0
for char in input.unicodeScalars {
hash = (hash &<< 5) &- hash &+ Int32(truncatingIfNeeded: char.value)
}
let count = Int32(avatarColors.count)
var index = abs(hash) % count
if index < 0 { index += count }
return Int(index)
}
/// Desktop parity: 2-letter initials from display name.
/// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift:209-223.
private static func initials(name: String, publicKey: String) -> String {
let words = name.trimmingCharacters(in: .whitespaces)
.split(whereSeparator: { $0.isWhitespace })
.filter { !$0.isEmpty }
switch words.count {
case 0:
return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased()
case 1:
return String(words[0].prefix(2)).uppercased()
default:
let first = words[0].first.map(String.init) ?? ""
let second = words[1].first.map(String.init) ?? ""
return (first + second).uppercased()
}
}
private static func uiColor(hex: UInt32, alpha: CGFloat = 1) -> UIColor {
UIColor(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
/// Generates a 50x50 Mantine "light" variant avatar for personal chats.
/// Dark base (#1A1B1E) + 15% tint overlay + shade-3 text matches AvatarView.swift:69-74.
private static func generateLetterAvatar(name: String, key: String) -> INImage? {
let size: CGFloat = 50
let colorIndex = abs(key.hashValue) % avatarColors.count
let colorIndex = avatarColorIndex(for: name, publicKey: key)
let colors = avatarColors[colorIndex]
let initial = String(name.prefix(1)).uppercased()
let text = initials(name: name, publicKey: key)
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0)
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
// Background circle.
let bgColor = UIColor(
red: CGFloat((colors.bg >> 16) & 0xFF) / 255,
green: CGFloat((colors.bg >> 8) & 0xFF) / 255,
blue: CGFloat(colors.bg & 0xFF) / 255,
alpha: 1
)
ctx.setFillColor(bgColor.cgColor)
// Mantine "light" variant: dark base + semi-transparent tint overlay.
ctx.setFillColor(uiColor(hex: mantineDarkBody).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
ctx.setFillColor(uiColor(hex: colors.tint, alpha: 0.15).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
// Initial letter.
let textColor = UIColor(
red: CGFloat((colors.text >> 16) & 0xFF) / 255,
green: CGFloat((colors.text >> 8) & 0xFF) / 255,
blue: CGFloat(colors.text & 0xFF) / 255,
alpha: 1
)
// Initials in shade-3 text color.
let textColor = uiColor(hex: colors.text)
let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
let textSize = (initial as NSString).size(withAttributes: attrs)
let textSize = (text as NSString).size(withAttributes: attrs)
let textRect = CGRect(
x: (size - textSize.width) / 2,
y: (size - textSize.height) / 2,
width: textSize.width,
height: textSize.height
)
(initial as NSString).draw(in: textRect, withAttributes: attrs)
(text as NSString).draw(in: textRect, withAttributes: attrs)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let pngData = image?.pngData() else { return nil }
return INImage(imageData: pngData)
}
/// Generates a 50x50 group avatar with person.2.fill icon on solid tint circle.
/// Matches ChatRowView.swift:99-106 (group avatar without photo).
private static func generateGroupAvatar(name: String, key: String) -> INImage? {
let size: CGFloat = 50
let colorIndex = avatarColorIndex(for: name, publicKey: key)
let colors = avatarColors[colorIndex]
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0)
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
// Solid tint color circle (ChatRowView parity).
ctx.setFillColor(uiColor(hex: colors.tint).cgColor)
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
// person.2.fill SF Symbol centered, white 90% opacity.
let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)
if let symbol = UIImage(systemName: "person.2.fill", withConfiguration: config)?
.withTintColor(.white.withAlphaComponent(0.9), renderingMode: .alwaysOriginal) {
let symbolSize = symbol.size
let symbolRect = CGRect(
x: (size - symbolSize.width) / 2,
y: (size - symbolSize.height) / 2,
width: symbolSize.width,
height: symbolSize.height
)
symbol.draw(in: symbolRect)
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()