Фикс: пуш-уведомления — in-app баннер (Telegram parity), аватарки Mantine, группы person.2.fill, антиспам вибраций
This commit is contained in:
@@ -641,7 +641,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -657,7 +657,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -681,7 +681,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 33;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -697,7 +697,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
66
Rosetta/Core/Services/InAppBannerManager.swift
Normal file
66
Rosetta/Core/Services/InAppBannerManager.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Telegram-parity in-app notification banner manager.
|
||||
/// Shows a floating banner at the top of the screen when a message arrives
|
||||
/// in foreground for a non-active, non-muted chat.
|
||||
///
|
||||
/// Queue: one banner at a time. New banner replaces current.
|
||||
/// Auto-dismiss: 5 seconds. Swipe-up or tap dismisses immediately.
|
||||
@MainActor
|
||||
final class InAppBannerManager: ObservableObject {
|
||||
|
||||
static let shared = InAppBannerManager()
|
||||
|
||||
@Published var currentBanner: BannerData?
|
||||
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
|
||||
/// Notification posted by SessionManager.processIncomingMessage
|
||||
/// when a foreground message should trigger an in-app banner.
|
||||
static let showBannerNotification = Notification.Name("InAppBannerManager.showBanner")
|
||||
|
||||
private init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: Self.showBannerNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let data = notification.object as? BannerData else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.show(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func show(_ data: BannerData) {
|
||||
// Replace current banner.
|
||||
dismissTask?.cancel()
|
||||
currentBanner = data
|
||||
|
||||
// Auto-dismiss after 5 seconds (Telegram parity).
|
||||
dismissTask = Task {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard !Task.isCancelled else { return }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
dismissTask?.cancel()
|
||||
currentBanner = nil
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
struct BannerData: Identifiable {
|
||||
let senderKey: String
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
let isGroup: Bool
|
||||
let verified: Int
|
||||
|
||||
var id: String { senderKey }
|
||||
}
|
||||
}
|
||||
@@ -1880,6 +1880,34 @@ final class SessionManager {
|
||||
// Sending 0x08 for every received message was causing a packet flood
|
||||
// that triggered server RST disconnects.
|
||||
|
||||
// Telegram parity: show in-app banner for foreground messages in non-active chats.
|
||||
if !fromMe && !effectiveFromSync && isAppInForeground {
|
||||
let isMuted: Bool = {
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
return mutedKeys.contains(opponentKey)
|
||||
}()
|
||||
if !MessageRepository.shared.isDialogActive(opponentKey) && !isMuted {
|
||||
let senderName = dialog?.opponentTitle ?? ""
|
||||
let preview: String = {
|
||||
if !text.isEmpty { return text }
|
||||
if !processedPacket.attachments.isEmpty { return "Photo" }
|
||||
return "New message"
|
||||
}()
|
||||
let bannerData = InAppBannerManager.BannerData(
|
||||
senderKey: opponentKey,
|
||||
senderName: senderName.isEmpty ? String(opponentKey.prefix(8)) : senderName,
|
||||
messagePreview: preview,
|
||||
isGroup: isGroupDialog,
|
||||
verified: dialog?.verified ?? 0
|
||||
)
|
||||
NotificationCenter.default.post(
|
||||
name: InAppBannerManager.showBannerNotification,
|
||||
object: bannerData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop/Android parity: mark as read if dialog is active, read-eligible,
|
||||
// app is in foreground, AND user is not idle (20s timeout).
|
||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
||||
|
||||
@@ -13,7 +13,7 @@ enum ReleaseNotes {
|
||||
body: """
|
||||
|
||||
**Пуш-уведомления**
|
||||
Только системные баннеры iOS — убраны кастомные in-app оверлеи, звуки и вибрации. Desktop-suppression: если читаешь на компьютере, телефон молчит 30 секунд.
|
||||
In-app баннеры (Telegram parity) — при получении сообщения внутри приложения. Исправлен спам вибраций при входе. Аватарки в пушах совпадают с приложением. Группы без фото — иконка людей. Desktop-suppression 30 сек.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
155
Rosetta/DesignSystem/Components/InAppBannerView.swift
Normal file
155
Rosetta/DesignSystem/Components/InAppBannerView.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - In-App Notification Banner (Telegram parity)
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Specs (from Telegram iOS `ChatMessageNotificationItem.swift`):
|
||||
/// - Panel: 74pt height, 24pt corner radius, 8pt horizontal margin
|
||||
/// - Avatar: 54pt circle, 12pt left inset
|
||||
/// - Title: semibold 16pt, white, 1 line
|
||||
/// - Message: regular 16pt, white 70% opacity, max 2 lines
|
||||
/// - Background: ultraThinMaterial (dark)
|
||||
/// - Slide from top: 0.4s, auto-dismiss: 5s, swipe-up dismiss
|
||||
struct InAppBannerView: View {
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
let senderKey: String
|
||||
let isGroup: Bool
|
||||
let onTap: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
private let panelHeight: CGFloat = 74
|
||||
private let cornerRadius: CGFloat = 24
|
||||
private let avatarSize: CGFloat = 54
|
||||
private let horizontalMargin: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
avatarView
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(senderName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(messagePreview)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: panelHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.padding(.horizontal, horizontalMargin)
|
||||
.offset(y: dragOffset)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 5)
|
||||
.onChanged { value in
|
||||
// Only allow upward drag (negative Y).
|
||||
if value.translation.height < 0 {
|
||||
dragOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
// Dismiss if dragged up > 20pt or velocity > 300.
|
||||
if value.translation.height < -20
|
||||
|| value.predictedEndTranslation.height < -100 {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
dragOffset = -200
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
onTap()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
let colorIndex = Self.avatarColorIndex(for: senderName, publicKey: senderKey)
|
||||
let pair = Self.avatarColors[colorIndex % Self.avatarColors.count]
|
||||
|
||||
if isGroup {
|
||||
// Group: solid tint circle + person.2.fill (ChatRowView parity).
|
||||
ZStack {
|
||||
Circle().fill(Color(hex: UInt(pair.tint)))
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
} else {
|
||||
// Personal: Mantine "light" variant (AvatarView parity).
|
||||
let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: senderKey)
|
||||
if let image = avatarImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
ZStack {
|
||||
Circle().fill(Color(hex: 0x1A1B1E))
|
||||
Circle().fill(Color(hex: UInt(pair.tint)).opacity(0.15))
|
||||
Text(Self.initials(name: senderName, publicKey: senderKey))
|
||||
.font(.system(size: avatarSize * 0.38, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Color(hex: UInt(pair.text)))
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color helpers (duplicated from Colors.swift — NSE can't import main target)
|
||||
|
||||
private static let avatarColors: [(tint: UInt32, text: UInt32)] = [
|
||||
(0x228be6, 0x74c0fc), (0x15aabf, 0x66d9e8), (0xbe4bdb, 0xe599f7),
|
||||
(0x40c057, 0x8ce99a), (0x4c6ef5, 0x91a7ff), (0x82c91e, 0xc0eb75),
|
||||
(0xfd7e14, 0xffc078), (0xe64980, 0xfaa2c1), (0xfa5252, 0xffa8a8),
|
||||
(0x12b886, 0x63e6be), (0x7950f2, 0xb197fc),
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
/// Max age (seconds) for a pending route to be considered fresh.
|
||||
static let pendingRouteExpirySeconds: TimeInterval = 3.0
|
||||
|
||||
/// Foreground transition timestamp — suppresses notification sounds for 3 seconds
|
||||
/// after entering foreground to prevent FCM burst vibrations.
|
||||
private static var lastForegroundTransitionTime: Date?
|
||||
|
||||
/// Consume pending notification route only if it was set recently.
|
||||
/// Returns the route if fresh (< `pendingRouteExpirySeconds`), nil otherwise.
|
||||
/// Always clears both statics regardless of freshness.
|
||||
@@ -67,6 +71,17 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
)
|
||||
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
|
||||
|
||||
// Track foreground transitions to suppress notification sound bursts.
|
||||
// FCM delivers queued pushes all at once on foreground — each triggers willPresent.
|
||||
// Sound is suppressed for 3 seconds after transition, banners still shown.
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willEnterForegroundNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
AppDelegate.lastForegroundTransitionTime = Date()
|
||||
}
|
||||
|
||||
// Clear caches on memory pressure to prevent system from killing the app.
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didReceiveMemoryWarningNotification,
|
||||
@@ -150,8 +165,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
return
|
||||
}
|
||||
|
||||
// For message notifications, skip if foreground (WebSocket handles real-time).
|
||||
guard application.applicationState != .active else {
|
||||
// For message notifications, skip in both foreground states (.active and .inactive).
|
||||
// Only create local notifications when truly in .background (NSE might not have run).
|
||||
// .inactive occurs during app transition (e.g., tapping a notification) — creating
|
||||
// local notifications here causes duplicate sounds.
|
||||
guard application.applicationState == .background else {
|
||||
completionHandler(.noData)
|
||||
return
|
||||
}
|
||||
@@ -178,18 +196,24 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
}
|
||||
Self.lastNotifTimestamps[dedupKey] = now
|
||||
|
||||
// Check if the server already sent a visible alert (aps.alert exists).
|
||||
// Check if the server already sent a visible alert (aps.alert exists)
|
||||
// or if NSE already handled this push (mutable-content: 1).
|
||||
let aps = userInfo["aps"] as? [String: Any]
|
||||
let hasVisibleAlert = aps?["alert"] != nil
|
||||
let hasMutableContent: Bool = {
|
||||
if let mc = aps?["mutable-content"] as? Int { return mc == 1 }
|
||||
if let mc = aps?["mutable-content"] as? Bool { return mc }
|
||||
return false
|
||||
}()
|
||||
|
||||
// Don't notify for muted chats.
|
||||
let mutedKeys = shared?.stringArray(forKey: "muted_chats_keys")
|
||||
?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
|
||||
|
||||
// If server sent visible alert, NSE handles sound+badge — don't double-count.
|
||||
// If muted, wake app but don't show notification (NSE also suppresses muted).
|
||||
if hasVisibleAlert || isMuted {
|
||||
// If server sent visible alert or mutable-content, NSE handles everything.
|
||||
// Don't create duplicate local notification — prevents sound/vibration spam.
|
||||
if hasVisibleAlert || hasMutableContent || isMuted {
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
@@ -500,25 +524,21 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? Self.extractSenderKey(from: userInfo)
|
||||
|
||||
if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
completionHandler([.banner, .sound])
|
||||
// Telegram parity: suppress ALL foreground notifications for current account.
|
||||
// Messages arrive via WebSocket in real-time — no need for system banners.
|
||||
// This prevents vibration/sound spam from FCM burst on foreground entry.
|
||||
completionHandler([])
|
||||
}
|
||||
|
||||
/// Determines whether a foreground notification should be suppressed.
|
||||
/// Testable: used by unit tests to verify suppression logic.
|
||||
/// Telegram parity: always returns [] — all foreground notifications suppressed.
|
||||
static func foregroundPresentationOptions(
|
||||
for userInfo: [AnyHashable: Any]
|
||||
) -> UNNotificationPresentationOptions {
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? extractSenderKey(from: userInfo)
|
||||
|
||||
if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
|
||||
return []
|
||||
}
|
||||
return [.banner, .sound]
|
||||
// Telegram parity: suppress all foreground notifications.
|
||||
// Messages arrive via WebSocket — system banners are redundant.
|
||||
return []
|
||||
}
|
||||
|
||||
/// Handle notification tap — navigate to the sender's chat or expand call.
|
||||
@@ -785,6 +805,7 @@ struct RosettaApp: App {
|
||||
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||
@State private var appState: AppState?
|
||||
@State private var transitionOverlay: Bool = false
|
||||
@StateObject private var bannerManager = InAppBannerManager.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -804,6 +825,9 @@ struct RosettaApp: App {
|
||||
.opacity(transitionOverlay ? 1 : 0)
|
||||
.allowsHitTesting(transitionOverlay)
|
||||
.animation(.easeInOut(duration: 0.035), value: transitionOverlay)
|
||||
|
||||
// Telegram parity: in-app notification banner.
|
||||
inAppBannerOverlay
|
||||
}
|
||||
}
|
||||
// NOTE: preferredColorScheme removed — DarkModeWrapper is the single
|
||||
@@ -822,6 +846,43 @@ struct RosettaApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - In-App Banner Overlay (Telegram parity)
|
||||
|
||||
@ViewBuilder
|
||||
private var inAppBannerOverlay: some View {
|
||||
VStack {
|
||||
if let banner = bannerManager.currentBanner {
|
||||
InAppBannerView(
|
||||
senderName: banner.senderName,
|
||||
messagePreview: banner.messagePreview,
|
||||
senderKey: banner.senderKey,
|
||||
isGroup: banner.isGroup,
|
||||
onTap: {
|
||||
bannerManager.dismiss()
|
||||
let route = ChatRoute(
|
||||
publicKey: banner.senderKey,
|
||||
title: banner.senderName,
|
||||
username: "",
|
||||
verified: banner.verified
|
||||
)
|
||||
AppDelegate.pendingChatRoute = route
|
||||
AppDelegate.pendingChatRouteTimestamp = Date()
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
)
|
||||
},
|
||||
onDismiss: {
|
||||
bannerManager.dismiss()
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: bannerManager.currentBanner?.id)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rootView(for state: AppState) -> some View {
|
||||
switch state {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -17,12 +17,13 @@ struct ForegroundNotificationTests {
|
||||
|
||||
// MARK: - System Banner Presentation
|
||||
|
||||
@Test("Non-suppressed chat shows system banner with sound")
|
||||
@Test("Telegram parity: all foreground notifications suppressed")
|
||||
func nonSuppressedShowsBanner() {
|
||||
clearActiveDialogs()
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"]
|
||||
let options = AppDelegate.foregroundPresentationOptions(for: userInfo)
|
||||
#expect(options == [.banner, .sound])
|
||||
// Telegram parity: willPresent returns [] — no banners/sound in foreground.
|
||||
#expect(options == [])
|
||||
}
|
||||
|
||||
@Test("Active chat suppresses system banner")
|
||||
|
||||
Reference in New Issue
Block a user