Фикс: пуш-уведомления — in-app баннер (Telegram parity), аватарки Mantine, группы person.2.fill, антиспам вибраций
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user