diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 2861608..efabb2c 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/Rosetta/Core/Services/InAppBannerManager.swift b/Rosetta/Core/Services/InAppBannerManager.swift new file mode 100644 index 0000000..7ecc622 --- /dev/null +++ b/Rosetta/Core/Services/InAppBannerManager.swift @@ -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? + + /// 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 } + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 3307536..607090d 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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) diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index 791d70f..d6a862e 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -13,7 +13,7 @@ enum ReleaseNotes { body: """ **Пуш-уведомления** - Только системные баннеры iOS — убраны кастомные in-app оверлеи, звуки и вибрации. Desktop-suppression: если читаешь на компьютере, телефон молчит 30 секунд. + In-app баннеры (Telegram parity) — при получении сообщения внутри приложения. Исправлен спам вибраций при входе. Аватарки в пушах совпадают с приложением. Группы без фото — иконка людей. Desktop-suppression 30 сек. """ ) ] diff --git a/Rosetta/DesignSystem/Components/InAppBannerView.swift b/Rosetta/DesignSystem/Components/InAppBannerView.swift new file mode 100644 index 0000000..cbfb799 --- /dev/null +++ b/Rosetta/DesignSystem/Components/InAppBannerView.swift @@ -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() + } + } +} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index d716457..0b1e69e 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -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 { diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index a9a9c6d..5fd9864 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -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() diff --git a/RosettaTests/ForegroundNotificationTests.swift b/RosettaTests/ForegroundNotificationTests.swift index 77110b0..543604b 100644 --- a/RosettaTests/ForegroundNotificationTests.swift +++ b/RosettaTests/ForegroundNotificationTests.swift @@ -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")