diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist index 9e60ee4..54bc01f 100644 --- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,17 +12,17 @@ RosettaLiveActivityWidget.xcscheme_^#shared#^_ orderHint - 1 + 2 RosettaNotificationService.xcscheme_^#shared#^_ orderHint - 3 + 1 RosettaUITests.xcscheme_^#shared#^_ orderHint - 2 + 3 SuppressBuildableAutocreation diff --git a/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/Contents.json b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/Contents.json new file mode 100644 index 0000000..fa5437a --- /dev/null +++ b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ic_chatslistpin@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ic_chatslistpin@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png new file mode 100644 index 0000000..f0fd40c Binary files /dev/null and b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@2x.png differ diff --git a/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png new file mode 100644 index 0000000..98dbed4 Binary files /dev/null and b/Rosetta/Assets.xcassets/PeerPinnedIcon.imageset/ic_chatslistpin@3x.png differ diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index 6950be9..9006245 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -82,6 +82,7 @@ struct Dialog: Identifiable, Codable, Equatable { var initials: String { if isSavedMessages { return "S" } + if isGroup { return RosettaColors.groupInitial(name: opponentTitle, publicKey: opponentKey) } return RosettaColors.initials(name: opponentTitle, publicKey: opponentKey) } } diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 1c77f86..9e1c667 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -349,7 +349,9 @@ final class DialogRepository { if pinnedCount >= ProtocolConstants.maxPinnedDialogs { return } } dialog.isPinned.toggle() - dialogs[opponentKey] = dialog + var updatedDialogs = dialogs + updatedDialogs[opponentKey] = dialog + dialogs = updatedDialogs _sortedKeysCache = nil persistDialog(dialog) } diff --git a/Rosetta/Core/Services/InAppBannerManager.swift b/Rosetta/Core/Services/InAppBannerManager.swift index 91bea77..1ec64ad 100644 --- a/Rosetta/Core/Services/InAppBannerManager.swift +++ b/Rosetta/Core/Services/InAppBannerManager.swift @@ -159,6 +159,14 @@ final class InAppBannerManager { overlayController = nil } + /// Dismiss banner only if it belongs to the specified dialog. + /// Called when a cross-device read receipt arrives via WebSocket. + func dismissIfMatchingDialog(_ dialogKey: String) { + guard let controller = overlayController, + controller.currentBannerKey == dialogKey else { return } + dismiss(animated: true) + } + // MARK: - Data struct BannerData: Identifiable { @@ -178,6 +186,7 @@ private final class InAppBannerOverlayViewController: UIViewController { private let passthroughView = InAppBannerPassthroughView() private var bannerView: InAppBannerView? + private(set) var currentBannerKey: String? var hasBanner: Bool { bannerView != nil @@ -202,6 +211,7 @@ private final class InAppBannerOverlayViewController: UIViewController { onDragEndedWithoutAction: @escaping () -> Void ) { dismissBanner(animated: false) {} + currentBannerKey = data.senderKey let banner = InAppBannerView(frame: .zero) banner.translatesAutoresizingMaskIntoConstraints = false @@ -247,6 +257,7 @@ private final class InAppBannerOverlayViewController: UIViewController { banner?.removeFromSuperview() if self.bannerView === banner { self.bannerView = nil + self.currentBannerKey = nil } self.passthroughView.bannerView = nil completion() diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 6667034..a14a7dd 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -54,6 +54,8 @@ final class SessionManager { /// Android parity: tracks the latest incoming message timestamp per dialog /// for which a read receipt was already sent. Prevents redundant sends. private var lastReadReceiptTimestamp: [String: Int64] = [:] + /// Dialogs recently read on another device — suppresses in-app banner for 30s. + private var desktopActiveDialogs: [String: Date] = [:] /// Cross-device reads received during sync — re-applied at BATCH_END. /// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead /// updates 0 rows. Re-applying after sync ensures the read state sticks. @@ -1359,6 +1361,25 @@ final class SessionManager { opponentKey: opponentKey, myPublicKey: ownKey ) + + // Desktop-active suppression: prevent in-app banner for 30s. + self.desktopActiveDialogs[opponentKey] = Date() + + // Dismiss in-app banner if it's showing for this dialog. + InAppBannerManager.shared.dismissIfMatchingDialog(opponentKey) + + // Clear delivered push notifications for this dialog + decrement badge. + Self.clearDeliveredNotifications(for: opponentKey) + + // Write to App Group so NSE also suppresses pushes for this dialog. + if let shared = UserDefaults(suiteName: "group.com.rosetta.dev") { + let now = Date().timeIntervalSince1970 + var recentlyRead = shared.dictionary(forKey: "nse_recently_read_dialogs") as? [String: Double] ?? [:] + recentlyRead[opponentKey] = now + recentlyRead = recentlyRead.filter { now - $0.value < 60 } + shared.set(recentlyRead, forKey: "nse_recently_read_dialogs") + } + // Race fix: if sync is in progress, the messages may not be // in DB yet (PacketRead arrives before sync messages). // Store for re-application at BATCH_END. @@ -1899,18 +1920,28 @@ final class SessionManager { .stringArray(forKey: "muted_chats_keys") ?? [] return mutedKeys.contains(opponentKey) }() - if !MessageRepository.shared.isDialogActive(opponentKey) && !isMuted { + // Desktop-active suppression: skip banner if dialog was read on another device < 30s ago. + let isDesktopActive: Bool = { + if let readDate = self.desktopActiveDialogs[opponentKey] { + return Date().timeIntervalSince(readDate) < 30 + } + return false + }() + if !MessageRepository.shared.isDialogActive(opponentKey) && !isMuted && !isDesktopActive { let senderName = dialog?.opponentTitle ?? "" let preview: String = { - if !text.isEmpty { return text } + if !text.isEmpty { + return EmojiParser.replaceShortcodes(in: text) + } if let firstAtt = processedPacket.attachments.first { switch firstAtt.type { case .image: return "Photo" case .file: return "File" case .voice: return "Voice message" + case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" - default: return "Attachment" + @unknown default: return "Attachment" } } return "New message" @@ -2420,6 +2451,24 @@ final class SessionManager { resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey) } + /// Clears delivered push notifications for a specific dialog and decrements badge. + /// Mirrors handleReadPush() logic from RosettaApp.swift — used by WebSocket read path. + private static func clearDeliveredNotifications(for dialogKey: String) { + let center = UNUserNotificationCenter.current() + center.getDeliveredNotifications { delivered in + let idsToRemove = delivered + .filter { $0.request.content.userInfo["sender_public_key"] as? String == dialogKey } + .map { $0.request.identifier } + guard !idsToRemove.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: idsToRemove) + let shared = UserDefaults(suiteName: "group.com.rosetta.dev") + let current = shared?.integer(forKey: "app_badge_count") ?? 0 + let newBadge = max(current - idsToRemove.count, 0) + shared?.set(newBadge, forKey: "app_badge_count") + UNUserNotificationCenter.current().setBadgeCount(newBadge) + } + } + private static func resolveReadPacketContext(_ packet: PacketRead, ownKey: String) -> PacketDialogContext? { resolveDialogContext(from: packet.fromPublicKey, to: packet.toPublicKey, ownKey: ownKey) } diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 8451cc0..cb5dc9e 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -211,6 +211,14 @@ enum RosettaColors { String(publicKey.prefix(2)).uppercased() } + /// Single-letter initial for group avatars (first letter of name, or first char of publicKey). + static func groupInitial(name: String, publicKey: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if let first = trimmed.first { return String(first).uppercased() } + if !publicKey.isEmpty { return String(publicKey.prefix(1)).uppercased() } + return "?" + } + static func initials(name: String, publicKey: String) -> String { let words = name.trimmingCharacters(in: .whitespaces) .split(whereSeparator: { $0.isWhitespace }) diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index c85b5a0..002b38a 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -64,27 +64,17 @@ private struct SettingsHighlightModifier: ViewModifier { func body(content: Content) -> some View { let shape = shape(for: position) - if #available(iOS 26, *) { - // iOS 26+: skip custom DragGesture — it conflicts with ScrollView - // gesture resolution on iOS 26, blocking scroll when finger lands - // on a button. Native press feedback is sufficient. - content - .buttonStyle(.plain) - .clipShape(shape) - } else { - // iOS < 26: custom press highlight via DragGesture - content - .buttonStyle(.plain) - .background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear)) - .clipShape(shape) - .simultaneousGesture( - DragGesture(minimumDistance: 5) - .updating($isPressed) { value, state, _ in - state = abs(value.translation.height) < 10 - && abs(value.translation.width) < 10 - } - ) - } + content + .buttonStyle(.plain) + .background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear)) + .clipShape(shape) + .simultaneousGesture( + DragGesture(minimumDistance: 5) + .updating($isPressed) { value, state, _ in + state = abs(value.translation.height) < 10 + && abs(value.translation.width) < 10 + } + ) } private func shape(for position: SettingsRowPosition) -> UnevenRoundedRectangle { diff --git a/Rosetta/DesignSystem/Components/CALayerFilters.swift b/Rosetta/DesignSystem/Components/CALayerFilters.swift index b9fea7a..da5a405 100644 --- a/Rosetta/DesignSystem/Components/CALayerFilters.swift +++ b/Rosetta/DesignSystem/Components/CALayerFilters.swift @@ -11,6 +11,10 @@ extension CALayer { return makeFilter(name: "colorMatrix") } + static func variableBlurFilter() -> NSObject? { + return makeFilter(name: "variableBlur") + } + private static func makeFilter(name: String) -> NSObject? { let className = ["CA", "Filter"].joined() guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil } diff --git a/Rosetta/DesignSystem/Components/GlassModifiers.swift b/Rosetta/DesignSystem/Components/GlassModifiers.swift index 1a364ff..6c66470 100644 --- a/Rosetta/DesignSystem/Components/GlassModifiers.swift +++ b/Rosetta/DesignSystem/Components/GlassModifiers.swift @@ -86,16 +86,12 @@ final class TelegramNavBlurUIView: UIView { /// iOS < 26: Telegram-style blur (UIBlurEffect(.light) + stripped filters). struct GlassNavBarModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - } else { - content - .toolbarBackground(.hidden, for: .navigationBar) - .background { - TelegramNavBlurView() - .ignoresSafeArea(edges: .top) - } - } + content + .toolbarBackground(.hidden, for: .navigationBar) + .background { + TelegramNavBlurView() + .ignoresSafeArea(edges: .top) + } } } @@ -111,12 +107,7 @@ extension View { /// iOS < 26: no-op (standard search bar appearance). struct GlassSearchBarModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .glassEffect(.regular, in: .capsule) - } else { - content - } + content } } diff --git a/Rosetta/DesignSystem/Components/InAppBannerView.swift b/Rosetta/DesignSystem/Components/InAppBannerView.swift index 3b1b777..496cab4 100644 --- a/Rosetta/DesignSystem/Components/InAppBannerView.swift +++ b/Rosetta/DesignSystem/Components/InAppBannerView.swift @@ -101,7 +101,7 @@ final class InAppBannerView: UIView { } if messageChanged { - messageLabel.text = messagePreview + messageLabel.text = Self.singleLinePreviewText(from: messagePreview) } resetTransientTransformState() @@ -173,7 +173,7 @@ final class InAppBannerView: UIView { let rawMessageSize = messageLabel.sizeThatFits( CGSize(width: textWidth, height: CGFloat.greatestFiniteMagnitude) ) - let maxMessageHeight = ceil(messageLabel.font.lineHeight * 2.0) + let maxMessageHeight = ceil(messageLabel.font.lineHeight) let messageHeight = ceil(min(rawMessageSize.height, maxMessageHeight)) let totalTextHeight = titleHeight + Metrics.verticalTextSpacing + messageHeight @@ -235,7 +235,7 @@ final class InAppBannerView: UIView { addSubview(titleLabel) messageLabel.font = .systemFont(ofSize: 15, weight: .regular) - messageLabel.numberOfLines = 2 + messageLabel.numberOfLines = 1 messageLabel.lineBreakMode = .byTruncatingTail addSubview(messageLabel) @@ -269,6 +269,12 @@ final class InAppBannerView: UIView { messageLabel.textColor = color } + private static func singleLinePreviewText(from text: String) -> String { + text + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + } + private func updateAvatar() { let avatarIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: senderKey) let tintColor = RosettaColors.avatarColor(for: avatarIndex) @@ -276,17 +282,19 @@ final class InAppBannerView: UIView { if isGroup { avatarImageView.isHidden = true - avatarInitialsLabel.isHidden = true - avatarTintOverlayView.isHidden = true + groupIconView.isHidden = true avatarBackgroundView.isHidden = false - avatarBackgroundView.backgroundColor = tintColor + avatarTintOverlayView.isHidden = false + avatarInitialsLabel.isHidden = false - groupIconView.isHidden = false - groupIconView.tintColor = UIColor.white.withAlphaComponent(0.9) - groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration( - UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) - ) + let isDark = traitCollection.userInterfaceStyle == .dark + avatarBackgroundView.backgroundColor = isDark + ? UIColor(red: 0x1A / 255.0, green: 0x1B / 255.0, blue: 0x1E / 255.0, alpha: 1.0) + : UIColor(red: 0xF1 / 255.0, green: 0xF3 / 255.0, blue: 0xF5 / 255.0, alpha: 1.0) + avatarTintOverlayView.backgroundColor = tintColor.withAlphaComponent(0.15) + avatarInitialsLabel.textColor = textColor + avatarInitialsLabel.text = RosettaColors.groupInitial(name: senderName, publicKey: senderKey) return } diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index 3f66907..7baba13 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -129,7 +129,6 @@ final class KeyboardTracker: ObservableObject { /// Buffers values and applies at 30fps via CADisplayLink coalescing /// to reduce ChatDetailView.body evaluations during swipe-to-dismiss. func updateFromKVO(keyboardHeight: CGFloat) { - if #available(iOS 26, *) { return } PerformanceLogger.shared.track("keyboard.kvo") guard !isAnimating else { return } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index 1bbc953..d37d728 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -14,7 +14,7 @@ private enum TabBarUIColors { static let selectionFill = UIColor { traits in traits.userInterfaceStyle == .dark ? UIColor.white.withAlphaComponent(0.07) - : UIColor.black.withAlphaComponent(0.06) + : UIColor.black.withAlphaComponent(0.12) } } @@ -175,6 +175,7 @@ final class RosettaTabBarUIView: UIView { private func applyAllColors() { cacheColorComponents() + selectionView.backgroundColor = TabBarUIColors.selectionFill for i in 0.. some View { - if #available(iOS 26.0, *) { content } - else { content.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) } + content.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) } } diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift index c1b18f4..5c70eb5 100644 --- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift +++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift @@ -109,9 +109,6 @@ final class TelegramGlassUIView: UIView { private var lastImageCornerRadius: CGFloat = -1 private var lastImageIsDark: Bool? - // iOS 26+ native glass - private var nativeGlassView: UIVisualEffectView? - private static let shadowInset: CGFloat = 32.0 override init(frame: CGRect) { @@ -121,39 +118,14 @@ final class TelegramGlassUIView: UIView { // touches from SwiftUI Buttons that use this view as .background. isUserInteractionEnabled = false - if #available(iOS 26.0, *) { - setupNativeGlass() - } else { - setupLegacyGlass() - } + setupLegacyGlass() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - // MARK: - iOS 26+ (native UIGlassEffect) - - @available(iOS 26.0, *) - private func setupNativeGlass() { - let effect = UIGlassEffect(style: .regular) - effect.isInteractive = false - effect.tintColor = UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor(white: 1.0, alpha: 0.025) - : UIColor(white: 0.0, alpha: 0.025) - } - let glassView = UIVisualEffectView(effect: effect) - glassView.clipsToBounds = true - glassView.layer.cornerCurve = .continuous - // Set initial corner radius — will be updated in layoutSubviews. - glassView.layer.cornerRadius = 22 - glassView.isUserInteractionEnabled = false - addSubview(glassView) - nativeGlassView = glassView - } - - // MARK: - iOS < 26 (CABackdropLayer + ColorMatrix + Mesh — Telegram parity) + // MARK: - Glass (CABackdropLayer + ColorMatrix + Mesh — Telegram parity) private func setupLegacyGlass() { // Shadow image view — positioned behind glass with negative inset @@ -224,11 +196,7 @@ final class TelegramGlassUIView: UIView { } else { radius = bounds.height / 2 } - if #available(iOS 26.0, *), let glassView = nativeGlassView { - glassView.layer.cornerRadius = radius - } else { - clippingContainer.cornerRadius = radius - } + clippingContainer.cornerRadius = radius } override func layoutSubviews() { @@ -245,13 +213,7 @@ final class TelegramGlassUIView: UIView { cornerRadius = bounds.height / 2 } - if #available(iOS 26.0, *), let glassView = nativeGlassView { - glassView.frame = bounds - glassView.layer.cornerRadius = cornerRadius - return - } - - // Legacy layout + // Layout clippingContainer.frame = bounds clippingContainer.cornerRadius = cornerRadius backdropLayer?.frame = bounds @@ -345,7 +307,8 @@ final class TelegramGlassUIView: UIView { override func didMoveToWindow() { super.didMoveToWindow() // Resolve images once view is in a window and has valid traitCollection - if nativeGlassView == nil { + // This implementation always uses legacy glass, so refresh only when backdrop exists. + if backdropLayer != nil { lastImageIsDark = nil setNeedsLayout() } diff --git a/Rosetta/DesignSystem/Components/VariableBlurEdgeView.swift b/Rosetta/DesignSystem/Components/VariableBlurEdgeView.swift new file mode 100644 index 0000000..2209711 --- /dev/null +++ b/Rosetta/DesignSystem/Components/VariableBlurEdgeView.swift @@ -0,0 +1,271 @@ +import UIKit + +// MARK: - VariableBlurEdgeView +// Port of Telegram-iOS EdgeEffectView + VariableBlurView. +// Creates a gradient blur at the bottom edge of the header using +// CABackdropLayer + variableBlur CAFilter. + +final class VariableBlurEdgeView: UIView { + + private let contentView = UIView() + private let contentMaskView = UIImageView() + private var backdropLayer: CALayer? + private let backdropDelegate = BackdropLayerDelegate.shared + private var gradientImage: UIImage? + private var lastEdgeSize: CGFloat = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + clipsToBounds = true + + contentView.mask = contentMaskView + addSubview(contentView) + + setupBackdropLayer() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + + /// Update the edge blur. + /// - Parameters: + /// - size: Full view size. + /// - edgeSize: Height of the gradient zone (typically 54pt). + /// - contentAlpha: Alpha of the tint content view (Telegram uses 0.85). + func update(size: CGSize, edgeSize: CGFloat, contentAlpha: CGFloat = 0.85) { + let bounds = CGRect(origin: .zero, size: size) + contentView.frame = bounds + contentMaskView.frame = bounds + contentView.alpha = contentAlpha + + if lastEdgeSize != edgeSize { + lastEdgeSize = edgeSize + let maskImage = Self.generateEdgeGradient(baseHeight: max(1, edgeSize)) + contentMaskView.image = maskImage + gradientImage = maskImage + } + + updateBlurFilter(size: size, edgeSize: edgeSize) + } + + func setTintColor(_ color: UIColor) { + contentView.backgroundColor = color + } + + // MARK: - Backdrop Layer + + private func setupBackdropLayer() { + guard let layer = BackdropLayerHelper.createBackdropLayer() else { return } + layer.delegate = backdropDelegate + BackdropLayerHelper.setScale(layer, scale: 0.5) + self.layer.insertSublayer(layer, at: 0) + backdropLayer = layer + } + + private func updateBlurFilter(size: CGSize, edgeSize: CGFloat) { + guard let backdropLayer else { return } + + let blurHeight = max(edgeSize, size.height - 14) + let blurFrame = CGRect(x: 0, y: 0, width: size.width, height: blurHeight) + backdropLayer.frame = blurFrame + + if #available(iOS 26.0, *) { + updateModernBlur(edgeSize: edgeSize, size: CGSize(width: size.width, height: blurHeight)) + } else { + updateLegacyBlur(edgeSize: edgeSize, size: CGSize(width: size.width, height: blurHeight)) + } + } + + // iOS 26+: uses inputSourceSublayerName with a mask_source sublayer + @available(iOS 26.0, *) + private func updateModernBlur(edgeSize: CGFloat, size: CGSize) { + guard let backdropLayer else { return } + + // Lazily add mask source sublayer + let maskSourceName = "mask_source" + let maskLayer: CALayer + if let existing = backdropLayer.sublayers?.first(where: { $0.name == maskSourceName }) { + maskLayer = existing + } else { + maskLayer = CALayer() + maskLayer.name = maskSourceName + backdropLayer.addSublayer(maskLayer) + } + + // Generate gradient for mask source + let gradientImg = generateBlurMask(edgeSize: edgeSize, totalHeight: size.height) + maskLayer.contents = gradientImg?.cgImage + maskLayer.frame = CGRect(origin: .zero, size: size) + + // Apply filter + guard let filter = CALayer.variableBlurFilter() else { return } + filter.setValue(1.0 as NSNumber, forKey: "inputRadius") + filter.setValue(maskSourceName, forKey: "inputSourceSublayerName") + filter.setValue(true as NSNumber, forKey: "inputNormalizeEdges") + backdropLayer.filters = [filter] + } + + // Pre-iOS 26: generates inputMaskImage directly + private func updateLegacyBlur(edgeSize: CGFloat, size: CGSize) { + guard let backdropLayer else { return } + + if let filter = CALayer.variableBlurFilter() { + filter.setValue(1.0 as NSNumber, forKey: "inputRadius") + filter.setValue(true as NSNumber, forKey: "inputNormalizeEdges") + + let maskImage = generateBlurMask(edgeSize: edgeSize, totalHeight: size.height) + if let cgImage = maskImage?.cgImage { + filter.setValue(cgImage, forKey: "inputMaskImage") + } + + backdropLayer.filters = [filter] + } else if let filter = CALayer.blurFilter() { + // Fallback: simple gaussian blur if variableBlur unavailable + filter.setValue(1.0 as NSNumber, forKey: "inputRadius") + backdropLayer.filters = [filter] + } + } + + /// Generates a 1-pixel-wide mask image for the variable blur. + /// The top portion (above edgeSize) is fully opaque (full blur), + /// the bottom `edgeSize` portion fades via the Telegram gradient curve. + private func generateBlurMask(edgeSize: CGFloat, totalHeight: CGFloat) -> UIImage? { + let height = min(800, totalHeight) + guard height > 0 else { return nil } + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: height)) + return renderer.image { ctx in + let context = ctx.cgContext + + // Constant-blur region at top + let constantHeight = max(0, height - edgeSize) + context.setFillColor(UIColor.black.cgColor) + context.fill(CGRect(x: 0, y: 0, width: 1, height: constantHeight)) + + // Gradient region at bottom (using Telegram gradient image) + if let gradient = gradientImage { + gradient.draw(in: CGRect(x: 0, y: constantHeight, width: 1, height: edgeSize)) + } + } + } + + // MARK: - Gradient Generation (Telegram 84-point curve, verbatim) + + static func generateEdgeGradientData(baseHeight: CGFloat) -> (alpha: [CGFloat], positions: [CGFloat]) { + let gradientColors: [CGFloat] = [ + 0.8470588235294118, 0.8431372549019608, 0.8392156862745098, + 0.8352941176470589, 0.8313725490196078, 0.8274509803921568, + 0.8235294117647058, 0.8196078431372549, 0.8156862745098039, + 0.8117647058823529, 0.807843137254902, 0.803921568627451, + 0.8, 0.7960784313725491, 0.792156862745098, + 0.788235294117647, 0.7843137254901961, 0.7803921568627451, + 0.7764705882352941, 0.7725490196078432, 0.7686274509803921, + 0.7647058823529411, 0.7607843137254902, 0.7568627450980392, + 0.7529411764705882, 0.7490196078431373, 0.7450980392156863, + 0.7411764705882353, 0.7372549019607844, 0.7333333333333334, + 0.7294117647058824, 0.7254901960784313, 0.7215686274509804, + 0.7176470588235294, 0.7137254901960784, 0.7098039215686274, + 0.7019607843137254, 0.6941176470588235, 0.6862745098039216, + 0.6784313725490196, 0.6705882352941177, 0.6588235294117647, + 0.6509803921568628, 0.6431372549019607, 0.6313725490196078, + 0.6235294117647059, 0.615686274509804, 0.603921568627451, + 0.596078431372549, 0.5882352941176471, 0.5764705882352941, + 0.5647058823529412, 0.5529411764705883, 0.5411764705882354, + 0.5294117647058824, 0.5176470588235293, 0.5058823529411764, + 0.49411764705882355, 0.4862745098039216, 0.4745098039215686, + 0.4627450980392157, 0.4549019607843138, 0.44313725490196076, + 0.43137254901960786, 0.41960784313725485, 0.4117647058823529, + 0.4, 0.388235294117647, 0.3764705882352941, + 0.3647058823529412, 0.3529411764705882, 0.3411764705882353, + 0.3294117647058824, 0.3176470588235294, 0.3058823529411765, + 0.2941176470588235, 0.2823529411764706, 0.2705882352941177, + 0.2588235294117647, 0.2431372549019608, 0.2313725490196078, + 0.21568627450980393, 0.19999999999999996, 0.18039215686274512, + 0.16078431372549018, 0.14117647058823535, 0.11764705882352944, + 0.09019607843137256, 0.04705882352941182, 0.0, + ] + + let gradientLocations: [CGFloat] = [ + 0.0, 0.020905923344947737, 0.059233449477351915, + 0.08710801393728224, 0.10801393728222997, 0.12195121951219512, + 0.13240418118466898, 0.14285714285714285, 0.15331010452961671, + 0.1602787456445993, 0.17073170731707318, 0.18118466898954705, + 0.1916376306620209, 0.20209059233449478, 0.20905923344947736, + 0.21254355400696864, 0.21951219512195122, 0.2264808362369338, + 0.23344947735191637, 0.23693379790940766, 0.24390243902439024, + 0.24738675958188153, 0.25435540069686413, 0.2578397212543554, + 0.2613240418118467, 0.2682926829268293, 0.27177700348432055, + 0.27526132404181186, 0.28222996515679444, 0.2857142857142857, + 0.289198606271777, 0.2926829268292683, 0.2961672473867596, + 0.29965156794425085, 0.30313588850174217, 0.30662020905923343, + 0.313588850174216, 0.3205574912891986, 0.32752613240418116, + 0.3344947735191638, 0.34146341463414637, 0.34843205574912894, + 0.3554006968641115, 0.3623693379790941, 0.3693379790940767, + 0.37630662020905925, 0.3797909407665505, 0.3867595818815331, + 0.39372822299651566, 0.397212543554007, 0.40418118466898956, + 0.41114982578397213, 0.4181184668989547, 0.4250871080139373, + 0.43205574912891986, 0.43902439024390244, 0.445993031358885, + 0.4529616724738676, 0.4564459930313589, 0.4634146341463415, + 0.47038327526132406, 0.4738675958188153, 0.4808362369337979, + 0.4878048780487805, 0.49477351916376305, 0.49825783972125437, + 0.5052264808362369, 0.5121951219512195, 0.519163763066202, + 0.5261324041811847, 0.5331010452961672, 0.5400696864111498, + 0.5470383275261324, 0.554006968641115, 0.5609756097560976, + 0.5679442508710801, 0.5749128919860628, 0.5818815331010453, + 0.5888501742160279, 0.5993031358885017, 0.6062717770034843, + 0.6167247386759582, 0.627177700348432, 0.6411149825783972, + 0.6585365853658537, 0.6759581881533101, 0.6968641114982579, + 0.7282229965156795, 0.7909407665505227, 1.0, + ] + + let norm = gradientColors.max()! + return ( + alpha: gradientColors.map { $0 / norm }, + positions: gradientLocations + ) + } + + static func generateEdgeGradient(baseHeight: CGFloat) -> UIImage { + let data = generateEdgeGradientData(baseHeight: baseHeight) + let colors = data.alpha.map { UIColor(white: 0, alpha: $0) } + return generateGradientImage( + height: baseHeight, + colors: colors, + locations: data.positions + ) + } + + private static func generateGradientImage( + height: CGFloat, + colors: [UIColor], + locations: [CGFloat] + ) -> UIImage { + let size = CGSize(width: 1, height: max(1, height)) + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let context = ctx.cgContext + let colorSpace = CGColorSpaceCreateDeviceRGB() + let cgColors = colors.map { $0.cgColor } as CFArray + var locs = locations.map { Float($0) }.map { CGFloat($0) } + guard let gradient = CGGradient( + colorsSpace: colorSpace, + colors: cgColors, + locations: &locs + ) else { return } + // Draw top-to-bottom: opaque at top, transparent at bottom + context.drawLinearGradient( + gradient, + start: CGPoint(x: 0, y: 0), + end: CGPoint(x: 0, y: size.height), + options: [] + ) + }.resizableImage( + withCapInsets: UIEdgeInsets(top: 0, left: 0, bottom: height, right: 0), + resizingMode: .stretch + ) + } +} diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index ca709b1..e69f769 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -117,21 +117,16 @@ private struct SeedCardStyle: ViewModifier { let color: Color func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) - } else { - content - .background { - RoundedRectangle(cornerRadius: 12) - .fill(color.opacity(0.12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.18), lineWidth: 0.5) - } - } - .clipShape(RoundedRectangle(cornerRadius: 12)) - } + content + .background { + RoundedRectangle(cornerRadius: 12) + .fill(color.opacity(0.12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.18), lineWidth: 0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12)) } } diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index cde3f06..2e356bd 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -87,14 +87,10 @@ struct AttachmentPanelView: View { // MARK: - Panel Content /// Figma: photos extend to bottom, glass tab bar pill floats over them. - /// iOS 26+: no background color — default Liquid Glass sheet. - /// iOS < 26: dark background + gradient behind tab bar. @ViewBuilder private var panelContent: some View { ZStack(alignment: .bottom) { - if #unavailable(iOS 26) { - RosettaColors.Adaptive.surface.ignoresSafeArea() - } + RosettaColors.Adaptive.surface.ignoresSafeArea() VStack(spacing: 0) { toolbar tabContent @@ -337,20 +333,16 @@ struct AttachmentPanelView: View { } .animation(.easeInOut(duration: 0.25), value: hasSelection) .background { - // iOS < 26: gradient fade behind tab bar. - // iOS 26+: no gradient — Liquid Glass pill is self-contained. - if #unavailable(iOS 26) { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3), - .init(color: RosettaColors.Adaptive.surface, location: 0.8), - ], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea(edges: .bottom) - } + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3), + .init(color: RosettaColors.Adaptive.surface, location: 0.8), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .bottom) } } @@ -673,12 +665,8 @@ private extension View { /// Shadow for iOS < 26 tab bar (matches RosettaTabBar's TabBarShadowModifier). private struct AttachmentTabBarShadowModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - } else { - content - .shadow(color: .black.opacity(0.12), radius: 20, y: 8) - } + content + .shadow(color: .black.opacity(0.12), radius: 20, y: 8) } } @@ -713,11 +701,7 @@ private extension View { private struct AttachmentSheetBackgroundModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - // iOS 26+: use default Liquid Glass sheet background. - content - } else if #available(iOS 16.4, *) { - // iOS < 26: opaque dark background (no glass on older iOS sheets). + if #available(iOS 16.4, *) { content.presentationBackground(RosettaColors.Adaptive.surface) } else { content diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index dbf7199..ec17c5f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -201,23 +201,8 @@ struct ChatDetailView: View { #if DEBUG let _ = PerformanceLogger.shared.track("chatDetail.bodyEval") #endif - // iOS 26+ and iOS < 26 use the same UIKit ComposerView bridge. - // #available branches stay explicit to keep platform separation intact. - Group { - if #available(iOS 26, *) { - chatArea - .ignoresSafeArea() - } else { - // iOS < 26: composer is inside NativeMessageListController. - // UIKit handles ALL keyboard/safe area insets manually via - // contentInsetAdjustmentBehavior = .never + applyInsets(). - // Tell SwiftUI to not adjust frame for ANY safe area edge — - // this ensures keyboardWillChangeFrameNotification reaches - // the embedded controller without interference. - chatArea - .ignoresSafeArea() - } - } + chatArea + .ignoresSafeArea() .overlay(alignment: .bottom) { if isMultiSelectMode { selectionActionBar @@ -522,13 +507,9 @@ private struct ChatDetailPrincipal: View { DialogRepository.shared.dialogs[route.publicKey] } - private var badgeSpacing: CGFloat { - if #available(iOS 26, *) { return 3 } else { return 4 } - } + private var badgeSpacing: CGFloat { 4 } - private var badgeSize: CGFloat { - if #available(iOS 26, *) { return 12 } else { return 14 } - } + private var badgeSize: CGFloat { 14 } private var titleText: String { if route.isSavedMessages { return "Saved Messages" } @@ -642,7 +623,9 @@ private struct ChatDetailToolbarAvatar: View { var body: some View { let _ = AvatarRepository.shared.avatarVersion // observation dependency for reactive updates let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) - let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey) + let initials = route.isSavedMessages ? "S" + : route.isGroup ? RosettaColors.groupInitial(name: titleText, publicKey: route.publicKey) + : RosettaColors.initials(name: titleText, publicKey: route.publicKey) let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey) AvatarView( @@ -707,49 +690,7 @@ private extension ChatDetailView { .buttonStyle(.plain) } - } else if #available(iOS 26, *) { - // iOS 26+ — original compact sizes with .glassEffect() - ToolbarItem(placement: .navigationBarLeading) { - Button { dismiss() } label: { - TelegramVectorIcon( - pathData: TelegramIconPath.backChevron, - viewBox: CGSize(width: 11, height: 20), - color: RosettaColors.Adaptive.text - ) - .frame(width: 11, height: 20) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } - } - .buttonStyle(.plain) - .accessibilityLabel("Back") - } - - ToolbarItem(placement: .principal) { - Button { openProfile() } label: { - ChatDetailPrincipal(route: route, viewModel: viewModel) - .padding(.horizontal, 12) - .frame(minWidth: 120) - .frame(height: 44) - .contentShape(Capsule()) - .background { - glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) - } - } - .buttonStyle(.plain) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button { openProfile() } label: { - ChatDetailToolbarAvatar(route: route, size: 35) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) } - } - .buttonStyle(.plain) - } } else { - // iOS < 26 — capsule back button, larger avatar, .thinMaterial ToolbarItem(placement: .navigationBarLeading) { Button { dismiss() } label: { backCapsuleButtonLabel } .buttonStyle(.plain) @@ -956,40 +897,7 @@ private extension ChatDetailView { /// Top: native SwiftUI Material blur with gradient mask — blurs content behind it. @ViewBuilder var chatEdgeGradients: some View { - if #available(iOS 26, *) { - VStack(spacing: 0) { - LinearGradient( - stops: [ - .init(color: gradientBase.opacity(0.85), location: 0.0), - .init(color: gradientBase.opacity(0.75), location: 0.2), - .init(color: gradientBase.opacity(0.55), location: 0.4), - .init(color: gradientBase.opacity(0.3), location: 0.6), - .init(color: gradientBase.opacity(0.12), location: 0.78), - .init(color: gradientBase.opacity(0.0), location: 1.0), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 100) - - Spacer() - - LinearGradient( - stops: [ - .init(color: gradientBase.opacity(0.0), location: 0.0), - .init(color: gradientBase.opacity(0.3), location: 0.3), - .init(color: gradientBase.opacity(0.65), location: 0.6), - .init(color: gradientBase.opacity(0.85), location: 1.0), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 54) - } - .ignoresSafeArea() - .allowsHitTesting(false) - } else { - VStack(spacing: 0) { + VStack(spacing: 0) { LinearGradient( stops: [ .init(color: gradientBase.opacity(0.85), location: 0.0), @@ -1017,9 +925,8 @@ private extension ChatDetailView { ) .frame(height: 44) } - .ignoresSafeArea() - .allowsHitTesting(false) - } + .ignoresSafeArea() + .allowsHitTesting(false) } /// Chat wallpaper — reads user selection from @AppStorage. @@ -1525,13 +1432,14 @@ private extension ChatDetailView { if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" } return stripped }() + let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText) // 3. For image/file with non-empty caption: show caption - if attachmentLabel != nil, !visibleText.isEmpty { return visibleText } + if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded } // 4. For image/file with no caption: show type label if let label = attachmentLabel { return label } // 5. No attachment: show text - if !visibleText.isEmpty { return message.text } + if !visibleTextDecoded.isEmpty { return visibleTextDecoded } return "" } @@ -1834,13 +1742,8 @@ private extension ChatDetailView { /// ChatListView & SettingsView keep `.applyGlassNavBar()` with `.regularMaterial`. private struct ChatDetailNavBarStyleModifier: ViewModifier { func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .toolbarBackground(.hidden, for: .navigationBar) - } else { - content - .toolbarBackground(.hidden, for: .navigationBar) - } + content + .toolbarBackground(.hidden, for: .navigationBar) } } diff --git a/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift b/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift index a22a19a..6bd9ed7 100644 --- a/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift +++ b/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift @@ -47,14 +47,11 @@ struct GroupInviteCardView: View { var body: some View { HStack(alignment: .top, spacing: 10) { - Circle() - .fill(status.color) - .frame(width: 44, height: 44) - .overlay { - Image(systemName: "person.2.fill") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(.white) - } + AvatarView( + initials: RosettaColors.groupInitial(name: title.isEmpty ? "Group" : title, publicKey: groupId), + colorIndex: RosettaColors.avatarColorIndex(for: title.isEmpty ? "Group" : title, publicKey: groupId), + size: 44 + ) VStack(alignment: .leading, spacing: 3) { Text(title.isEmpty ? "Group" : title) diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index fb4e5ba..747b62c 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -191,7 +191,8 @@ struct MessageCellView: View, Equatable { if !parsed.fileName.isEmpty { return parsed.fileName } return file.id.isEmpty ? "File" : file.id } - if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } return "Message" }() @@ -568,11 +569,12 @@ struct MessageCellView: View, Equatable { let senderName = senderDisplayName(for: reply.publicKey) let previewText: String = { let trimmed = reply.message.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { return reply.message } + if !trimmed.isEmpty { return EmojiParser.replaceShortcodes(in: reply.message) } if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } // Android/Desktop parity: show "Call" for call attachments in reply quote if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index f70146e..70be037 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -215,7 +215,7 @@ final class NativeMessageCell: UICollectionViewCell { // Group Invite Card private let groupInviteContainer = UIView() private let groupInviteIconBg = UIView() - private let groupInviteIcon = UIImageView() + private let groupInviteInitialLabel = UILabel() private let groupInviteTitleLabel = UILabel() private let groupInviteStatusLabel = UILabel() private let groupInviteButton = UIButton(type: .custom) @@ -498,13 +498,12 @@ final class NativeMessageCell: UICollectionViewCell { // Group Invite Card groupInviteIconBg.layer.cornerRadius = 22 groupInviteIconBg.clipsToBounds = true - groupInviteIcon.image = UIImage( - systemName: "person.2.fill", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) - ) - groupInviteIcon.tintColor = .white - groupInviteIcon.contentMode = .scaleAspectFit - groupInviteIconBg.addSubview(groupInviteIcon) + let boldFont = UIFont.systemFont(ofSize: 17, weight: .bold) + groupInviteInitialLabel.font = boldFont.fontDescriptor.withDesign(.rounded) + .map { UIFont(descriptor: $0, size: 0) } ?? boldFont + groupInviteInitialLabel.textColor = .white + groupInviteInitialLabel.textAlignment = .center + groupInviteIconBg.addSubview(groupInviteInitialLabel) groupInviteContainer.addSubview(groupInviteIconBg) groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) @@ -1058,7 +1057,31 @@ final class NativeMessageCell: UICollectionViewCell { textLabel.isHidden = true groupInviteString = message.text - groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle + let inviteTitle = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle + groupInviteTitleLabel.text = inviteTitle + groupInviteInitialLabel.text = RosettaColors.groupInitial(name: inviteTitle, publicKey: layout.groupInviteGroupId) + + // Mantine light variant for invite avatar + let colorIdx = RosettaColors.avatarColorIndex(for: inviteTitle, publicKey: layout.groupInviteGroupId) + let isDark = traitCollection.userInterfaceStyle == .dark + let tintColor = RosettaColors.avatarColor(for: colorIdx) + let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0 + let base: UIColor = isDark + ? UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) + : .white + base.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + tintColor.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + groupInviteIconBg.backgroundColor = UIColor( + red: br + (tr - br) * tintAlpha, + green: bg + (tg - bg) * tintAlpha, + blue: bb + (tb - bb) * tintAlpha, + alpha: 1 + ) + groupInviteInitialLabel.textColor = isDark + ? RosettaColors.avatarTextColor(for: colorIdx) + : tintColor // Local membership check (fast, MainActor) let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId) @@ -1095,7 +1118,7 @@ final class NativeMessageCell: UICollectionViewCell { case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759 case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1) } - groupInviteIconBg.backgroundColor = color + // Avatar bg is set by Mantine colors in configure(), not by status groupInviteTitleLabel.textColor = isOutgoing ? .white : color let statusText: String @@ -1361,7 +1384,7 @@ final class NativeMessageCell: UICollectionViewCell { let cW = layout.bubbleSize.width let topY: CGFloat = 10 groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44) - groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20) + groupInviteInitialLabel.frame = groupInviteIconBg.bounds let textX: CGFloat = 64 let textW = cW - textX - 10 groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19) diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index a513203..21d29c4 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -437,8 +437,8 @@ final class NativeMessageListController: UIViewController { if displayText.isEmpty { entry = ReplyDataCacheEntry(replyName: nil, replyText: nil, replyMessageId: nil, forwardSenderName: name, forwardSenderKey: senderKey) } else { - let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message - entry = ReplyDataCacheEntry(replyName: name, replyText: EmojiParser.replaceShortcodes(in: rawReplyMsg), replyMessageId: first.message_id, forwardSenderName: nil, forwardSenderKey: nil) + let previewText = Self.replyPreviewText(for: first) + entry = ReplyDataCacheEntry(replyName: name, replyText: previewText, replyMessageId: first.message_id, forwardSenderName: nil, forwardSenderKey: nil) } self.replyDataCache[msg.id] = entry return entry @@ -478,6 +478,22 @@ final class NativeMessageListController: UIViewController { return Self.timestampFormatter.string(from: date) } + /// Reply quote preview text for UIKit cells. + /// Mirrors SwiftUI parity rules: explicit text first, then attachment type labels. + private static func replyPreviewText(for reply: ReplyMessageData) -> String { + let trimmed = reply.message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return EmojiParser.replaceShortcodes(in: reply.message) + } + if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } + if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } + if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } + return "Attachment" + } + private func setupDataSource() { dataSource = UICollectionViewDiffableDataSource( collectionView: collectionView diff --git a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift index cc7443e..1f2886b 100644 --- a/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift +++ b/Rosetta/Features/Chats/ChatDetail/PhotoPreviewView.swift @@ -220,15 +220,8 @@ struct PhotoPreviewView: View { .animation(.easeInOut(duration: 0.25), value: isKeyboardVisible) } - @ViewBuilder private var captionBarBackground: some View { - if #available(iOS 26, *) { - RoundedRectangle(cornerRadius: 21, style: .continuous) - .fill(.clear) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) - } else { - TelegramGlassRoundedRect(cornerRadius: 21) - } + TelegramGlassRoundedRect(cornerRadius: 21) } // MARK: - Toolbar Row (Telegram 1:1 match) diff --git a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift index a0edaf4..2b7c712 100644 --- a/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift +++ b/Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift @@ -44,12 +44,7 @@ final class RecordingLockView: UIView { private var currentLockness: CGFloat = 0 private var visualState: VisualState = .lock - private var usesComponentVisuals: Bool { - if #available(iOS 26.0, *) { - return true - } - return false - } + private var usesComponentVisuals: Bool { false } private var isShowingStopButton: Bool { visualState == .stop diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 8391640..1e10aa6 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -1,814 +1,21 @@ -import Combine import SwiftUI -import UIKit -// MARK: - Navigation State (survives parent re-renders) - -@MainActor -final class ChatListNavigationState: ObservableObject { - @Published var path: [ChatRoute] = [] -} - -// MARK: - ChatListView - -/// The root chat list screen. -/// -/// **IMPORTANT:** This view's `body` must NOT read any `@Observable` singleton -/// (`ProtocolManager`, `DialogRepository`, `AccountManager`, `SessionManager`) -/// directly. Such reads create implicit Observation tracking, causing the -/// NavigationStack to rebuild on every property change (e.g. during handshake) -/// and triggering "Update NavigationRequestObserver tried to update multiple -/// times per frame" → app freeze. -/// -/// All `@Observable` access is isolated in dedicated child views: -/// - `DeviceVerificationContentRouter` → `ProtocolManager` -/// - `ToolbarStoriesAvatar` → `AccountManager` / `SessionManager` -/// - `ChatListDialogContent` → `DialogRepository` (via ViewModel) +/// Legacy compatibility wrapper. +/// Active implementation is UIKit in ChatListUIKitView. struct ChatListView: View { @Binding var isSearchActive: Bool @Binding var isDetailPresented: Bool - @StateObject private var viewModel = ChatListViewModel() - @StateObject private var navigationState = ChatListNavigationState() - @State private var searchText = "" - @State private var hasPinnedChats = false - @State private var showRequestChats = false - @State private var showNewGroupSheet = false - @State private var showJoinGroupSheet = false - @State private var showNewChatActionSheet = false - @State private var searchBarExpansion: CGFloat = 1.0 - @FocusState private var isSearchFocused: Bool var body: some View { - NavigationStack(path: $navigationState.path) { - VStack(spacing: 0) { - // Custom search bar — collapses on scroll (Telegram: 54pt distance) - customSearchBar - .padding(.horizontal, 16) - .padding(.top, isSearchActive ? 8 : 8 * searchBarExpansion) - .padding(.bottom, isSearchActive ? 8 : 8 * searchBarExpansion) - .frame(height: isSearchActive ? 60 : max(0, 60 * searchBarExpansion), alignment: .top) - .clipped() - .opacity(isSearchActive ? 1 : Double(searchBarExpansion)) - .allowsHitTesting(isSearchActive || searchBarExpansion > 0.5) - .background( - (hasPinnedChats && !isSearchActive - ? RosettaColors.Adaptive.pinnedSectionBackground - : Color.clear - ).ignoresSafeArea(.all, edges: .top) - ) - - if isSearchActive { - ChatListSearchContent( - searchText: searchText, - viewModel: viewModel, - onSelectRecent: { searchText = $0 }, - onOpenDialog: { route in - navigationState.path.append(route) - // Delay search dismissal so NavigationStack processes - // the push before the search overlay is removed. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - isSearchActive = false - isSearchFocused = false - searchText = "" - viewModel.setSearchQuery("") - } - } - ) - } else { - normalContent - } - } - .background(RosettaColors.Adaptive.background.ignoresSafeArea()) - .navigationBarTitleDisplayMode(.inline) - .toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar) - .toolbar { toolbarContent } - .modifier(ChatListToolbarBackgroundModifier()) - .onChange(of: isSearchActive) { _, _ in - searchBarExpansion = 1.0 - } - .onChange(of: searchText) { _, newValue in - viewModel.setSearchQuery(newValue) - } - .navigationDestination(for: ChatRoute.self) { route in - ChatDetailView( - route: route, - onPresentedChange: { presented in - isDetailPresented = presented - } - ) - // Force a fresh ChatDetailView when route changes at the same stack depth. - // This avoids stale message content when switching chats via notification/banner. - .id(route.publicKey) - } - .navigationDestination(isPresented: $showRequestChats) { - RequestChatsView( - viewModel: viewModel, - navigationState: navigationState - ) - } - .onAppear { - isDetailPresented = !navigationState.path.isEmpty || showRequestChats - } - .onChange(of: navigationState.path) { _, newPath in - isDetailPresented = !newPath.isEmpty || showRequestChats - } - .onChange(of: showRequestChats) { _, showing in - isDetailPresented = !navigationState.path.isEmpty || showing - } - } - .tint(RosettaColors.figmaBlue) - .confirmationDialog("New", isPresented: $showNewChatActionSheet) { - Button("New Group") { showNewGroupSheet = true } - Button("Join Group") { showJoinGroupSheet = true } - Button("Cancel", role: .cancel) {} - } - .sheet(isPresented: $showNewGroupSheet) { - NavigationStack { - GroupSetupView { route in - showNewGroupSheet = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - navigationState.path = [route] - } - } - } - .presentationDetents([.large]) - } - .sheet(isPresented: $showJoinGroupSheet) { - NavigationStack { - GroupJoinView { route in - showJoinGroupSheet = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - navigationState.path = [route] - } - } - } - .presentationDetents([.large]) - } - .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in - guard let route = notification.object as? ChatRoute else { return } - AppDelegate.pendingChatRoute = nil - AppDelegate.pendingChatRouteTimestamp = nil - - // Already showing this chat. - if !showRequestChats, navigationState.path.last?.publicKey == route.publicKey { - return - } - - // If user is in a chat already, push target chat immediately on top. - // This avoids the list flash while still creating a fresh destination. - if !navigationState.path.isEmpty { - navigationState.path.append(route) - return - } - - // If Requests screen is open, close it first, then open chat. - if showRequestChats { - showRequestChats = false - DispatchQueue.main.async { - navigationState.path = [route] - } - return - } - - // Root chat-list state: open target chat directly. - navigationState.path = [route] - } - .onAppear { - // Cold start fallback: ChatListView didn't exist when notification was posted. - // Expiry guard (3s) prevents stale routes from firing on tab switches — - // critical for iOS < 26 pager (ZStack opacity 0→1 re-fires .onAppear). - consumePendingRouteIfFresh() - } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - // Background→foreground fallback: covers edge cases where .onReceive - // subscription hasn't re-activated after background→foreground transition. - // Harmless if .onReceive already consumed the route (statics are nil). - consumePendingRouteIfFresh() - } - } - - // MARK: - Cancel Search - - private func cancelSearch() { - withAnimation(.easeInOut(duration: 0.3)) { - isSearchActive = false - } - isSearchFocused = false - searchText = "" - viewModel.setSearchQuery("") - } - - /// Consume pending notification route only if it was set within the last 3 seconds. - /// Prevents stale routes (from failed .onReceive) from being consumed on tab switches. - private func consumePendingRouteIfFresh() { - guard let route = AppDelegate.consumeFreshPendingRoute() else { return } - Task { @MainActor in - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms for NavigationStack settle - navigationState.path = [route] - } - } -} - -// MARK: - Custom Search Bar - -private extension ChatListView { - var customSearchBar: some View { - HStack(spacing: 10) { - // Search bar capsule - ZStack { - // Centered placeholder: magnifier + "Search" - if searchText.isEmpty && !isSearchActive { - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Color.gray) - Text("Search") - .font(.system(size: 17)) - .foregroundStyle(Color.gray) - } - .allowsHitTesting(false) - } - - // Active: left-aligned magnifier + TextField - if isSearchActive { - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(Color.gray) - - TextField("Search", text: $searchText) - .font(.system(size: 17)) - .foregroundStyle(RosettaColors.Adaptive.text) - .focused($isSearchFocused) - .submitLabel(.search) - - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 15)) - .foregroundStyle(Color.gray) - } - } - } - .padding(.horizontal, 12) - } - } - .frame(height: 44) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .onTapGesture { - if !isSearchActive { - withAnimation(.easeInOut(duration: 0.14)) { - isSearchActive = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isSearchFocused = true - } - } - } - .background { - if isSearchActive { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(RosettaColors.Adaptive.searchBarFill) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) - } - } else { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(RosettaColors.Adaptive.searchBarFill) - } - } - .onChange(of: isSearchFocused) { _, focused in - if focused && !isSearchActive { - withAnimation(.easeInOut(duration: 0.14)) { - isSearchActive = true - } - } - } - - // Circular X button (visible only when search is active) - if isSearchActive { - Button { - cancelSearch() - } label: { - Image("toolbar-xmark") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 19, height: 19) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(width: 36, height: 36) - .padding(3) - } - .buttonStyle(.plain) - .background { - Circle() - .fill(RosettaColors.Adaptive.searchBarFill) - .overlay { - Circle() - .strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5) - } - } - .transition(.opacity.combined(with: .scale(scale: 0.5))) - } - } - } -} - -// MARK: - Normal Content - -private extension ChatListView { - @ViewBuilder - var normalContent: some View { - // Observation-isolated router — reads ProtocolManager in its own scope. - // Shows full-screen DeviceConfirmView when awaiting approval, - // or normal chat list with optional device approval banner otherwise. - DeviceVerificationContentRouter( - viewModel: viewModel, - navigationState: navigationState, - onShowRequests: { showRequestChats = true }, - onPinnedStateChange: { pinned in - if hasPinnedChats != pinned { - withAnimation(.easeInOut(duration: 0.25)) { - hasPinnedChats = pinned - } - } - }, - onScrollOffsetChange: { expansion in - searchBarExpansion = expansion - } + ChatListUIKitView( + isSearchActive: $isSearchActive, + isDetailPresented: $isDetailPresented ) } } -// MARK: - Toolbar - -private extension ChatListView { - @ToolbarContentBuilder - var toolbarContent: some ToolbarContent { - if !isSearchActive { - if #available(iOS 26, *) { - // iOS 26+ — original compact toolbar (no capsules, system icons) - ToolbarItem(placement: .navigationBarLeading) { - Button { } label: { - Text("Edit") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - } - - ToolbarItem(placement: .principal) { - HStack(spacing: 4) { - ToolbarStoriesAvatar() - ToolbarTitleView() - } - } - - ToolbarItemGroup(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - Button { } label: { - Image(systemName: "camera") - .font(.system(size: 16, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - .accessibilityLabel("Camera") - Button { showNewChatActionSheet = true } label: { - Image(systemName: "square.and.pencil") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - .padding(.bottom, 2) - .accessibilityLabel("New chat") - } - } - } else { - // iOS < 26 — capsule-styled toolbar with custom icons - ToolbarItem(placement: .navigationBarLeading) { - Button { } label: { - Text("Edit") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - .frame(height: 40) - .padding(.horizontal, 10) - } - .buttonStyle(.plain) - .glassCapsule() - } - - ToolbarItem(placement: .principal) { - HStack(spacing: 4) { - ToolbarStoriesAvatar() - ToolbarTitleView() - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 0) { - Button { } label: { - Image("toolbar-add-chat") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 22, height: 22) - .frame(width: 40, height: 40) - } - .buttonStyle(.plain) - .accessibilityLabel("Add chat") - - Button { showNewChatActionSheet = true } label: { - Image("toolbar-compose") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .frame(width: 40, height: 40) - } - .buttonStyle(.plain) - .accessibilityLabel("New chat") - } - .foregroundStyle(RosettaColors.Adaptive.text) - .glassCapsule() - } - } - } - } -} - -// MARK: - Toolbar Background Modifier - -struct ChatListToolbarBackgroundModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .toolbarBackground(.visible, for: .navigationBar) - .applyGlassNavBar() - } else { - content - .toolbarBackground(.hidden, for: .navigationBar) - } - } -} - -// MARK: - Toolbar Title (observation-isolated) - -/// Reads `ProtocolManager.shared.connectionState` and `SessionManager.shared.syncBatchInProgress` -/// in its own observation scope. State changes are absorbed here, -/// not cascaded to the parent ChatListView / NavigationStack. -private struct ToolbarTitleView: View { - var body: some View { - let state = ProtocolManager.shared.connectionState - let isSyncing = SessionManager.shared.syncBatchInProgress - - if state == .authenticated && isSyncing { - ToolbarStatusLabel(title: "Updating...") - } else if state == .authenticated { - Text("Chats") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - .contentTransition(.numericText()) - .animation(.easeInOut(duration: 0.25), value: state) - .onTapGesture { - NotificationCenter.default.post(name: .chatListScrollToTop, object: nil) - } - } else { - ToolbarStatusLabel(title: "Connecting...") - } - } -} - -/// Status text label without spinner (spinner is in ToolbarStoriesAvatar). -private struct ToolbarStatusLabel: View { - let title: String - - var body: some View { - Text(title) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - } -} - -// MARK: - Toolbar Stories Avatar (observation-isolated) - -/// Reads `AccountManager`, `SessionManager`, and `ProtocolManager` in its own observation scope. -/// Shows a spinning arc loader during connecting/syncing, then crossfades to avatar. -private struct ToolbarStoriesAvatar: View { - @State private var isSpinning = false - - var body: some View { - let pk = AccountManager.shared.currentAccount?.publicKey ?? "" - let state = ProtocolManager.shared.connectionState - let isSyncing = SessionManager.shared.syncBatchInProgress - let isLoading = state != .authenticated || isSyncing - - let initials = RosettaColors.initials( - name: SessionManager.shared.displayName, publicKey: pk - ) - let colorIdx = RosettaColors.avatarColorIndex(for: SessionManager.shared.displayName, publicKey: pk) - let _ = AvatarRepository.shared.avatarVersion - let avatar = AvatarRepository.shared.loadAvatar(publicKey: pk) - - ZStack { - // Avatar — visible when loaded - AvatarView(initials: initials, colorIndex: colorIdx, size: 28, image: avatar) - .opacity(isLoading ? 0 : 1) - - // Spinning arc loader — visible during connecting/syncing - Circle() - .trim(from: 0.05, to: 0.78) - .stroke( - RosettaColors.figmaBlue, - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .frame(width: 20, height: 20) - .rotationEffect(.degrees(isSpinning ? 360 : 0)) - .opacity(isLoading ? 1 : 0) - } - .animation(.easeInOut(duration: 0.3), value: isLoading) - .onAppear { isSpinning = true } - .animation(.linear(duration: 1).repeatForever(autoreverses: false), value: isSpinning) - } -} - -// MARK: - Sync-Aware Empty State (observation-isolated) - -/// Shows "Syncing..." indicator when sync is in progress, otherwise shows empty state. -/// Reads `SessionManager.syncBatchInProgress` in its own observation scope. -private struct SyncAwareEmptyState: View { - var body: some View { - let isSyncing = SessionManager.shared.syncBatchInProgress - if isSyncing { - VStack(spacing: 16) { - Spacer().frame(height: 120) - ProgressView() - .tint(RosettaColors.Adaptive.textSecondary) - Text("Syncing conversations…") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - Spacer() - } - .frame(maxWidth: .infinity) - } else { - ChatEmptyStateView(searchText: "") - } - } -} - -// MARK: - Request Chats Row (Telegram Archive style) - -/// Shown at the top of the chat list when there are incoming message requests. -/// Matches ChatRowView sizing: height 78, pl-10, pr-16, avatar 62px. -private struct RequestChatsRow: View { - let count: Int - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 0) { - // Avatar: solid blue circle with white icon (62px) - ZStack { - Circle() - .fill(RosettaColors.primaryBlue) - .frame(width: 62, height: 62) - - Image(systemName: "tray.and.arrow.down") - .font(.system(size: 24, weight: .medium)) - .foregroundStyle(.white) - } - .padding(.trailing, 10) - - // Content section — matches ChatRowView.contentSection layout - VStack(alignment: .leading, spacing: 0) { - // Title row - Text("Request Chats") - .font(.system(size: 17, weight: .medium)) - .tracking(-0.43) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - - // Subtitle row (count) - Text(count == 1 ? "1 request" : "\(count) requests") - .font(.system(size: 15)) - .tracking(-0.23) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 63, alignment: .top) - .padding(.top, 8) - } - .padding(.leading, 10) - .padding(.trailing, 16) - .frame(height: 78) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -// MARK: - Device Verification Content Router (observation-isolated) - -/// Reads `ProtocolManager` in its own observation scope. -/// During handshake, `connectionState` changes 4+ times rapidly — this view -/// absorbs those re-renders instead of cascading them to the NavigationStack. -/// -/// Device confirmation (THIS device waiting) is handled by full-screen overlay -/// in MainTabView (DeviceConfirmOverlay). This router only handles the -/// approval banner (ANOTHER device requesting access on primary device). -private struct DeviceVerificationContentRouter: View { - @ObservedObject var viewModel: ChatListViewModel - @ObservedObject var navigationState: ChatListNavigationState - var onShowRequests: () -> Void = {} - var onPinnedStateChange: (Bool) -> Void = { _ in } - var onScrollOffsetChange: (CGFloat) -> Void = { _ in } - - var body: some View { - let proto = ProtocolManager.shared - - VStack(spacing: 0) { - // Banner for approving ANOTHER device (primary device side) - if let pendingDevice = proto.pendingDeviceVerification { - DeviceApprovalBanner( - device: pendingDevice, - onAccept: { proto.acceptDevice(pendingDevice.deviceId) }, - onDecline: { proto.declineDevice(pendingDevice.deviceId) } - ) - } - - ChatListDialogContent( - viewModel: viewModel, - navigationState: navigationState, - onShowRequests: onShowRequests, - onPinnedStateChange: onPinnedStateChange, - onScrollOffsetChange: onScrollOffsetChange - ) - } - } -} - -// MARK: - Dialog Content (observation-isolated) - -/// Reads `DialogRepository` (via ViewModel) in its own observation scope. -/// Changes to dialogs only re-render this list, not the NavigationStack. -private struct ChatListDialogContent: View { - @ObservedObject var viewModel: ChatListViewModel - @ObservedObject var navigationState: ChatListNavigationState - var onShowRequests: () -> Void = {} - var onPinnedStateChange: (Bool) -> Void = { _ in } - var onScrollOffsetChange: (CGFloat) -> Void = { _ in } - - /// Desktop parity: track typing dialogs from MessageRepository (@Published). - @State private var typingDialogs: [String: Set] = [:] - - var body: some View { - #if DEBUG - let _ = PerformanceLogger.shared.track("chatList.bodyEval") - #endif - // CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking. - // Without this, ChatListDialogContent only observes viewModel (ObservableObject) - // which never publishes objectWillChange for dialog mutations. - // The read forces SwiftUI to re-evaluate body when dialogs dict changes. - let _ = DialogRepository.shared.dialogs.count - // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter). - let pinned = viewModel.allModePinned - let unpinned = viewModel.allModeUnpinned - let requestsCount = viewModel.requestsCount - - Group { - if pinned.isEmpty && unpinned.isEmpty && !viewModel.isLoading { - SyncAwareEmptyState() - } else { - dialogList( - pinned: pinned, - unpinned: unpinned, - requestsCount: requestsCount - ) - } - } - .onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 } - .onAppear { - onPinnedStateChange(!pinned.isEmpty) - } - } - - // MARK: - Dialog List (UIKit UICollectionView) - - private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View { - Group { - if viewModel.isLoading { - // Shimmer skeleton during initial load (SwiftUI — simple, not perf-critical) - List { - ForEach(0..<8, id: \.self) { _ in - ChatRowShimmerView() - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - } else { - // UIKit UICollectionView — Telegram-level scroll performance - let isSyncing = SessionManager.shared.syncBatchInProgress - ChatListCollectionView( - pinnedDialogs: pinned, - unpinnedDialogs: unpinned, - requestsCount: requestsCount, - typingDialogs: typingDialogs, - isSyncing: isSyncing, - isLoading: viewModel.isLoading, - onSelectDialog: { dialog in - navigationState.path.append(ChatRoute(dialog: dialog)) - }, - onDeleteDialog: { dialog in - viewModel.deleteDialog(dialog) - }, - onTogglePin: { dialog in - viewModel.togglePin(dialog) - }, - onToggleMute: { dialog in - viewModel.toggleMute(dialog) - }, - onPinnedStateChange: onPinnedStateChange, - onShowRequests: onShowRequests, - onScrollOffsetChange: onScrollOffsetChange, - onMarkAsRead: { dialog in - viewModel.markAsRead(dialog) - } - ) - } - } - } -} - -// MARK: - Device Approval Banner - -/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline. -/// Desktop: DeviceVerify.tsx — height 65px, centered text (dimmed), two transparent buttons. -private struct DeviceApprovalBanner: View { - let device: DeviceEntry - let onAccept: () -> Void - let onDecline: () -> Void - - @State private var showAcceptConfirmation = false - - var body: some View { - VStack(spacing: 8) { - Text("New login from \(device.deviceName) (\(device.deviceOs))") - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.white.opacity(0.45)) - .multilineTextAlignment(.center) - - HStack(spacing: 24) { - Button("Accept") { - showAcceptConfirmation = true - } - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.primaryBlue) - - Button("Decline") { - onDecline() - } - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(RosettaColors.error.opacity(0.8)) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .alert("Accept new device", isPresented: $showAcceptConfirmation) { - Button("Accept") { onAccept() } - Button("Cancel", role: .cancel) {} - } message: { - Text("Are you sure you want to accept this device? This will allow it to access your account.") - } - } -} - -// MARK: - iOS 26+ Classic Swipe Actions - -/// iOS 26: disable Liquid Glass on the List so swipe action buttons use -/// solid colors (same as iOS < 26). Uses UIAppearance override. -private struct ClassicSwipeActionsModifier: ViewModifier { - func body(content: Content) -> some View { - content.onAppear { - if #available(iOS 26, *) { - // Disable glass on UITableView-backed List swipe actions. - let appearance = UITableView.appearance() - appearance.backgroundColor = .clear - } - } - } -} - #Preview("Chat List") { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) .preferredColorScheme(.dark) } -#Preview("Search Active") { - ChatListView(isSearchActive: .constant(true), isDetailPresented: .constant(false)) - .preferredColorScheme(.dark) -} - diff --git a/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift b/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift index b2b1136..712276f 100644 --- a/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift +++ b/Rosetta/Features/Chats/ChatList/DeviceConfirmView.swift @@ -79,23 +79,13 @@ private struct DeviceConfirmExitButtonStyle: ButtonStyle { private let fillColor = RosettaColors.error func makeBody(configuration: Configuration) -> some View { - Group { - if #available(iOS 26, *) { - configuration.label - .background { - Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85)) - } - .glassEffect(.regular, in: Capsule()) - } else { - configuration.label - .background { - Capsule() - .fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85)) - } - .clipShape(Capsule()) + configuration.label + .background { + Capsule() + .fill(fillColor.opacity(configuration.isPressed ? 0.6 : 0.85)) } - } - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + .clipShape(Capsule()) + .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) } } diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index c153a69..72c5342 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -1,98 +1,6 @@ -import Lottie import SwiftUI import UIKit -// MARK: - RequestChatsView (SwiftUI shell — toolbar + navigation only) - -/// Screen showing incoming message requests — opened from the "Request Chats" -/// row at the top of the main chat list (Telegram Archive style). -/// List content rendered by UIKit RequestChatsController for performance parity. -struct RequestChatsView: View { - @ObservedObject var viewModel: ChatListViewModel - @ObservedObject var navigationState: ChatListNavigationState - @Environment(\.dismiss) private var dismiss - - var body: some View { - Group { - if viewModel.requestsModeDialogs.isEmpty { - RequestsEmptyStateView() - } else { - let isSyncing = SessionManager.shared.syncBatchInProgress - RequestChatsCollectionView( - dialogs: viewModel.requestsModeDialogs, - isSyncing: isSyncing, - onSelectDialog: { dialog in - navigationState.path.append(ChatRoute(dialog: dialog)) - }, - onDeleteDialog: { dialog in - viewModel.deleteDialog(dialog) - } - ) - } - } - .background(RosettaColors.Adaptive.background.ignoresSafeArea()) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { dismiss() } label: { - backCapsuleLabel - } - .buttonStyle(.plain) - } - - ToolbarItem(placement: .principal) { - Text("Request Chats") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) - } - } - .modifier(ChatListToolbarBackgroundModifier()) - .enableSwipeBack() - } - - // MARK: - Capsule Back Button (matches ChatDetailView) - - private var backCapsuleLabel: some View { - TelegramVectorIcon( - pathData: TelegramIconPath.backChevron, - viewBox: CGSize(width: 11, height: 20), - color: .white - ) - .frame(width: 11, height: 20) - .allowsHitTesting(false) - .frame(width: 36, height: 36) - .frame(height: 44) - .padding(.horizontal, 4) - .background { - TelegramGlassCapsule() - } - } -} - -// MARK: - RequestChatsCollectionView (UIViewControllerRepresentable bridge) - -private struct RequestChatsCollectionView: UIViewControllerRepresentable { - let dialogs: [Dialog] - let isSyncing: Bool - var onSelectDialog: ((Dialog) -> Void)? - var onDeleteDialog: ((Dialog) -> Void)? - - func makeUIViewController(context: Context) -> RequestChatsController { - let controller = RequestChatsController() - controller.onSelectDialog = onSelectDialog - controller.onDeleteDialog = onDeleteDialog - controller.updateDialogs(dialogs, isSyncing: isSyncing) - return controller - } - - func updateUIViewController(_ controller: RequestChatsController, context: Context) { - controller.onSelectDialog = onSelectDialog - controller.onDeleteDialog = onDeleteDialog - controller.updateDialogs(dialogs, isSyncing: isSyncing) - } -} - // MARK: - RequestChatsController (UIKit) /// Pure UIKit UICollectionView controller for request chats list. @@ -114,12 +22,22 @@ final class RequestChatsController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .clear + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) setupCollectionView() setupCellRegistration() setupDataSource() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyBottomInsets() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + applyBottomInsets() + } + // MARK: - Collection View private func setupCollectionView() { @@ -134,14 +52,14 @@ final class RequestChatsController: UIViewController { collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = .clear + collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background) collectionView.delegate = self collectionView.showsHorizontalScrollIndicator = false collectionView.showsVerticalScrollIndicator = false collectionView.alwaysBounceHorizontal = false collectionView.alwaysBounceVertical = true - collectionView.contentInset.bottom = 0 - collectionView.verticalScrollIndicatorInsets.bottom = 0 + collectionView.contentInsetAdjustmentBehavior = .never + applyBottomInsets() view.addSubview(collectionView) NSLayoutConstraint.activate([ @@ -152,6 +70,13 @@ final class RequestChatsController: UIViewController { ]) } + private func applyBottomInsets() { + guard collectionView != nil else { return } + let inset = view.safeAreaInsets.bottom + collectionView.contentInset.bottom = inset + collectionView.verticalScrollIndicatorInsets.bottom = inset + } + private func setupCellRegistration() { cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, dialog in @@ -237,33 +162,3 @@ extension RequestChatsController: UICollectionViewDelegate { } } } - -// MARK: - Requests Empty State - -/// Shown when there are no incoming requests. -/// Design: folder Lottie + title + subtitle. -private struct RequestsEmptyStateView: View { - var body: some View { - VStack(spacing: 0) { - LottieView(animationName: "folder_empty", loopMode: .playOnce, animationSpeed: 1.0) - .frame(width: 150, height: 150) - - Spacer().frame(height: 24) - - Text("No Requests") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .multilineTextAlignment(.center) - - Spacer().frame(height: 8) - - Text("New message requests will appear here") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .offset(y: -40) - } -} diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index 195c982..319dc00 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -74,6 +74,7 @@ final class ChatListCell: UICollectionViewCell { private var wasBadgeVisible = false private var wasMentionBadgeVisible = false private var isSystemChat = false + private var isSeparatorFullWidth = false // MARK: - Init @@ -152,7 +153,7 @@ final class ChatListCell: UICollectionViewCell { // Message messageLabel.font = .systemFont(ofSize: 15, weight: .regular) - messageLabel.numberOfLines = 2 + messageLabel.numberOfLines = 1 messageLabel.lineBreakMode = .byTruncatingTail contentView.addSubview(messageLabel) @@ -193,11 +194,12 @@ final class ChatListCell: UICollectionViewCell { // Pin icon pinnedIconView.contentMode = .scaleAspectFit - pinnedIconView.image = UIImage(systemName: "pin.fill")?.withConfiguration( + let fallbackPinImage = UIImage(systemName: "pin.fill")?.withConfiguration( UIImage.SymbolConfiguration(pointSize: 13, weight: .regular) ) + pinnedIconView.image = (UIImage(named: "PeerPinnedIcon") ?? fallbackPinImage)? + .withRenderingMode(.alwaysTemplate) pinnedIconView.isHidden = true - pinnedIconView.transform = CGAffineTransform(rotationAngle: .pi / 4) contentView.addSubview(pinnedIconView) // Separator @@ -326,11 +328,13 @@ final class ChatListCell: UICollectionViewCell { } if !pinnedIconView.isHidden { - let pinS: CGFloat = 16 + let pinSize = pinnedIconView.image?.size ?? CGSize(width: 20, height: 20) + let contentRectMaxY = h - 13.0 pinnedIconView.frame = CGRect( - x: badgeRightEdge - pinS, - y: badgeY + floor((CellLayout.badgeDiameter - pinS) / 2), - width: pinS, height: pinS + x: badgeRightEdge - pinSize.width, + y: contentRectMaxY - pinSize.height - 2.0, + width: pinSize.width, + height: pinSize.height ) badgeRightEdge = pinnedIconView.frame.minX - CellLayout.badgeSpacing } @@ -376,10 +380,11 @@ final class ChatListCell: UICollectionViewCell { // ── Separator ── let separatorHeight = 1.0 / scale + let separatorX = isSeparatorFullWidth ? 0 : CellLayout.separatorInset separatorView.frame = CGRect( - x: CellLayout.separatorInset, + x: separatorX, y: h - separatorHeight, - width: w - CellLayout.separatorInset, + width: w - separatorX, height: separatorHeight ) } @@ -456,7 +461,9 @@ final class ChatListCell: UICollectionViewCell { // Pin pinnedIconView.isHidden = !(dialog.isPinned && dialog.unreadCount == 0) - pinnedIconView.tintColor = secondaryColor + pinnedIconView.tintColor = isDark + ? UIColor(red: 0x76/255, green: 0x76/255, blue: 0x77/255, alpha: 1) + : UIColor(red: 0xB6/255, green: 0xB6/255, blue: 0xBB/255, alpha: 1) setNeedsLayout() } @@ -485,12 +492,19 @@ final class ChatListCell: UICollectionViewCell { avatarImageView.isHidden = false avatarBackgroundView.isHidden = true } else if dialog.isGroup { - avatarBackgroundView.backgroundColor = UIColor(colorPair.tint) - groupIconView.isHidden = false - groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration( - UIImage.SymbolConfiguration(pointSize: 24, weight: .medium) - ) - groupIconView.tintColor = .white.withAlphaComponent(0.9) + let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) + let baseColor = isDark ? mantineDarkBody : .white + let tintUIColor = UIColor(colorPair.tint) + let tintAlpha: CGFloat = isDark ? 0.15 : 0.10 + avatarBackgroundView.backgroundColor = baseColor.blended(with: tintUIColor, alpha: tintAlpha) + avatarInitialsLabel.isHidden = false + avatarInitialsLabel.text = dialog.initials + avatarInitialsLabel.font = .systemFont( + ofSize: CellLayout.avatarDiameter * 0.38, weight: .bold + ).rounded() + avatarInitialsLabel.textColor = isDark + ? UIColor(colorPair.text) + : tintUIColor } else { // Initials — Mantine "light" variant (matches AvatarView.swift) let mantineDarkBody = UIColor(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255, alpha: 1) @@ -689,10 +703,10 @@ final class ChatListCell: UICollectionViewCell { authorLabel.isHidden = false authorLabel.text = senderName authorLabel.textColor = titleColor - messageLabel.numberOfLines = 1 // 1 line when author shown + messageLabel.numberOfLines = 1 // always single-line subtitle } else { authorLabel.isHidden = true - messageLabel.numberOfLines = 2 + messageLabel.numberOfLines = 1 } messageLabel.attributedText = nil @@ -861,7 +875,7 @@ final class ChatListCell: UICollectionViewCell { onlineIndicator.isHidden = true contentView.backgroundColor = .clear messageLabel.attributedText = nil - messageLabel.numberOfLines = 2 + messageLabel.numberOfLines = 1 authorLabel.isHidden = true // Typing indicator typingDotsView.stopAnimating() @@ -873,6 +887,7 @@ final class ChatListCell: UICollectionViewCell { badgeContainer.transform = .identity mentionImageView.transform = .identity isSystemChat = false + isSeparatorFullWidth = false } // MARK: - Highlight @@ -909,7 +924,13 @@ final class ChatListCell: UICollectionViewCell { // MARK: - Separator Visibility func setSeparatorHidden(_ hidden: Bool) { + setSeparatorStyle(hidden: hidden, fullWidth: false) + } + + func setSeparatorStyle(hidden: Bool, fullWidth: Bool) { separatorView.isHidden = hidden + isSeparatorFullWidth = fullWidth + setNeedsLayout() } } diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index 9db26e4..09c63c2 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -25,6 +25,7 @@ final class ChatListCollectionController: UIViewController { var onTogglePin: ((Dialog) -> Void)? var onToggleMute: ((Dialog) -> Void)? var onPinnedStateChange: ((Bool) -> Void)? + var onPinnedHeaderFractionChange: ((CGFloat) -> Void)? var onShowRequests: (() -> Void)? var onScrollToTopRequested: (() -> Void)? var onScrollOffsetChange: ((CGFloat) -> Void)? @@ -38,6 +39,11 @@ final class ChatListCollectionController: UIViewController { private(set) var typingDialogs: [String: Set] = [:] private(set) var isSyncing: Bool = false private var lastReportedExpansion: CGFloat = 1.0 + private var lastReportedPinnedHeaderFraction: CGFloat = -1.0 + private let searchCollapseDistance: CGFloat = 54 + private var searchHeaderExpansion: CGFloat = 1.0 + private var hasInitializedTopOffset = false + private var isPinnedFractionReportScheduled = false // MARK: - UI @@ -47,13 +53,7 @@ final class ChatListCollectionController: UIViewController { private var requestsCellRegistration: UICollectionView.CellRegistration! private let floatingTabBarTotalHeight: CGFloat = 72 private var chatListBottomInset: CGFloat { - if #available(iOS 26, *) { - return 0 - } else { - // contentInsetAdjustmentBehavior(.automatic) already contributes safe-area bottom. - // Add only the remaining space covered by the custom floating tab bar. - return max(0, floatingTabBarTotalHeight - view.safeAreaInsets.bottom) - } + floatingTabBarTotalHeight } // Dialog lookup by ID for cell configuration @@ -63,20 +63,24 @@ final class ChatListCollectionController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .clear + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) setupCollectionView() setupCellRegistrations() setupDataSource() setupScrollToTop() } + deinit { + NotificationCenter.default.removeObserver(self, name: .chatListScrollToTop, object: nil) + } + // MARK: - Collection View Setup private func setupCollectionView() { let layout = createLayout() collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.backgroundColor = .clear + collectionView.backgroundColor = UIColor(RosettaColors.Adaptive.background) collectionView.delegate = self collectionView.prefetchDataSource = self collectionView.keyboardDismissMode = .onDrag @@ -84,8 +88,8 @@ final class ChatListCollectionController: UIViewController { collectionView.showsHorizontalScrollIndicator = false collectionView.alwaysBounceVertical = true collectionView.alwaysBounceHorizontal = false - collectionView.contentInsetAdjustmentBehavior = .automatic - applyBottomInsets() + collectionView.contentInsetAdjustmentBehavior = .never + applyInsets() view.addSubview(collectionView) NSLayoutConstraint.activate([ @@ -98,19 +102,99 @@ final class ChatListCollectionController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - applyBottomInsets() + applyInsets() + if !hasInitializedTopOffset { + collectionView.setContentOffset( + CGPoint(x: 0, y: -collectionView.contentInset.top), + animated: false + ) + hasInitializedTopOffset = true + } + schedulePinnedHeaderFractionReport() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - applyBottomInsets() + applyInsets() } - private func applyBottomInsets() { + private func applyInsets() { guard collectionView != nil else { return } - let inset = chatListBottomInset - collectionView.contentInset.bottom = inset - collectionView.verticalScrollIndicatorInsets.bottom = inset + let oldTopInset = collectionView.contentInset.top + let topInset = view.safeAreaInsets.top + (searchCollapseDistance * searchHeaderExpansion) + let bottomInset = chatListBottomInset + collectionView.contentInset.top = topInset + collectionView.contentInset.bottom = bottomInset + collectionView.verticalScrollIndicatorInsets.top = topInset + collectionView.verticalScrollIndicatorInsets.bottom = bottomInset + + guard hasInitializedTopOffset, + !collectionView.isDragging, + !collectionView.isDecelerating else { return } + + let delta = topInset - oldTopInset + if abs(delta) > 0.1 { + CATransaction.begin() + CATransaction.setDisableActions(true) + collectionView.contentOffset.y -= delta + CATransaction.commit() + } + } + + func setSearchHeaderExpansion(_ expansion: CGFloat) { + let clamped = max(0.0, min(1.0, expansion)) + guard abs(searchHeaderExpansion - clamped) > 0.002 else { return } + + CATransaction.begin() + CATransaction.setDisableActions(true) + searchHeaderExpansion = clamped + applyInsets() + CATransaction.commit() + reportPinnedHeaderFraction() + } + + private func schedulePinnedHeaderFractionReport(force: Bool = false) { + if isPinnedFractionReportScheduled { return } + isPinnedFractionReportScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isPinnedFractionReportScheduled = false + self.reportPinnedHeaderFraction(force: force) + } + } + + private func calculatePinnedHeaderFraction() -> CGFloat { + guard collectionView != nil, + view.window != nil, + collectionView.window != nil, + !pinnedDialogs.isEmpty else { return 0.0 } + + var maxPinnedOffset: CGFloat = 0.0 + for cell in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + sectionForIndexPath(indexPath) == .pinned else { + continue + } + maxPinnedOffset = max(maxPinnedOffset, cell.frame.maxY) + } + + let viewportInsetTop = collectionView.contentInset.top + guard viewportInsetTop > 0 else { return 0.0 } + + if maxPinnedOffset >= viewportInsetTop { + return 1.0 + } + + return max(0.0, min(1.0, maxPinnedOffset / viewportInsetTop)) + } + + private func reportPinnedHeaderFraction(force: Bool = false) { + let fraction = calculatePinnedHeaderFraction() + if !force, abs(fraction - lastReportedPinnedHeaderFraction) < 0.005 { + return + } + lastReportedPinnedHeaderFraction = fraction + onPinnedHeaderFractionChange?(fraction) } private func createLayout() -> UICollectionViewCompositionalLayout { @@ -159,16 +243,26 @@ final class ChatListCollectionController: UIViewController { guard let self else { return } let typingUsers = self.typingDialogs[dialog.opponentKey] cell.configure(with: dialog, isSyncing: self.isSyncing, typingUsers: typingUsers) - // Hide separator for last cell in pinned/unpinned section + + // Separator rules: + // 1) last pinned row -> full-width separator + // 2) last unpinned row -> hidden separator + // 3) others -> regular inset separator let section = self.sectionForIndexPath(indexPath) let isLastInPinned = section == .pinned && indexPath.item == self.pinnedDialogs.count - 1 let isLastInUnpinned = section == .unpinned && indexPath.item == self.unpinnedDialogs.count - 1 - cell.setSeparatorHidden(isLastInPinned || isLastInUnpinned) + if isLastInPinned { + cell.setSeparatorStyle(hidden: false, fullWidth: true) + } else { + cell.setSeparatorStyle(hidden: isLastInUnpinned, fullWidth: false) + } } requestsCellRegistration = UICollectionView.CellRegistration { - cell, indexPath, count in - cell.configure(count: count) + [weak self] cell, _, count in + let hasPinned = !(self?.pinnedDialogs.isEmpty ?? true) + // Requests row separator should be hidden when pinned section exists. + cell.configure(count: count, showBottomSeparator: !hasPinned) } } @@ -245,16 +339,18 @@ final class ChatListCollectionController: UIViewController { snapshot.appendSections([.unpinned]) snapshot.appendItems(newUnpinnedIds, toSection: .unpinned) - dataSource.apply(snapshot, animatingDifferences: true) + dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in + self?.reportPinnedHeaderFraction(force: true) + } + } else { + reportPinnedHeaderFraction() } // Always reconfigure ONLY visible cells (cheap — just updates content, no layout rebuild) reconfigureVisibleCells() - // Notify SwiftUI about pinned state - DispatchQueue.main.async { [weak self] in - self?.onPinnedStateChange?(!pinned.isEmpty) - } + // Notify host immediately so top chrome reacts in the same frame. + onPinnedStateChange?(!pinned.isEmpty) } /// Directly reconfigure only visible cells — no snapshot rebuild, no animation. @@ -268,7 +364,7 @@ final class ChatListCollectionController: UIViewController { let typingUsers = typingDialogs[dialog.opponentKey] chatCell.configure(with: dialog, isSyncing: isSyncing, typingUsers: typingUsers) } else if let reqCell = cell as? ChatListRequestsCell { - reqCell.configure(count: requestsCount) + reqCell.configure(count: requestsCount, showBottomSeparator: pinnedDialogs.isEmpty) } } } @@ -369,11 +465,29 @@ extension ChatListCollectionController: UICollectionViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { // Only react to user-driven scroll, not programmatic/layout changes guard scrollView.isDragging || scrollView.isDecelerating else { return } - let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - let expansion = max(0.0, min(1.0, 1.0 - offset / 54.0)) - guard abs(expansion - lastReportedExpansion) > 0.005 else { return } - lastReportedExpansion = expansion - onScrollOffsetChange?(expansion) + let offset = scrollView.contentOffset.y + view.safeAreaInsets.top + searchCollapseDistance + let expansion = max(0.0, min(1.0, 1.0 - offset / searchCollapseDistance)) + if abs(expansion - lastReportedExpansion) > 0.005 { + lastReportedExpansion = expansion + onScrollOffsetChange?(expansion) + } + reportPinnedHeaderFraction() + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + // Telegram snap-to-edge: if search bar is partially visible, snap to + // fully visible (>50%) or fully hidden (<50%). + guard lastReportedExpansion > 0.0 && lastReportedExpansion < 1.0 else { return } + let safeTop = view.safeAreaInsets.top + if lastReportedExpansion < 0.5 { + targetContentOffset.pointee.y = -safeTop + } else { + targetContentOffset.pointee.y = -(safeTop + searchCollapseDistance) + } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { @@ -529,7 +643,7 @@ final class ChatListRequestsCell: UICollectionViewCell { separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH) } - func configure(count: Int) { + func configure(count: Int, showBottomSeparator: Bool) { let isDark = traitCollection.userInterfaceStyle == .dark titleLabel.textColor = isDark ? .white : .black subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests" @@ -537,6 +651,7 @@ final class ChatListRequestsCell: UICollectionViewCell { separatorView.backgroundColor = isDark ? UIColor(red: 0x54/255, green: 0x54/255, blue: 0x58/255, alpha: 0.55) : UIColor(red: 0xC8/255, green: 0xC7/255, blue: 0xCC/255, alpha: 1) + separatorView.isHidden = !showBottomSeparator } override func preferredLayoutAttributesFitting( diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift new file mode 100644 index 0000000..a04b1bb --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -0,0 +1,1538 @@ +import Combine +import Observation +import SwiftUI +import UIKit + +struct ChatListUIKitView: View { + @Binding var isSearchActive: Bool + @Binding var isDetailPresented: Bool + + var body: some View { + ChatListUIKitContainer( + isSearchActive: $isSearchActive, + isDetailPresented: $isDetailPresented + ) + } +} + +private struct ChatListUIKitContainer: UIViewControllerRepresentable { + @Binding var isSearchActive: Bool + @Binding var isDetailPresented: Bool + + func makeCoordinator() -> ChatListUIKitCoordinator { + ChatListUIKitCoordinator( + isSearchActive: $isSearchActive, + isDetailPresented: $isDetailPresented + ) + } + + func makeUIViewController(context: Context) -> UINavigationController { + let root = ChatListRootViewController() + root.onSearchActiveChanged = { [weak coordinator = context.coordinator] active in + coordinator?.setSearchActive(active) + } + root.onDetailPresentedChanged = { [weak coordinator = context.coordinator] presented in + coordinator?.setDetailPresented(presented) + } + + let nav = UINavigationController(rootViewController: root) + nav.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + nav.navigationBar.tintColor = UIColor(RosettaColors.Adaptive.text) + nav.navigationBar.prefersLargeTitles = false + return nav + } + + func updateUIViewController(_ navigationController: UINavigationController, context: Context) { + context.coordinator.isSearchActive = $isSearchActive + context.coordinator.isDetailPresented = $isDetailPresented + } +} + +private final class ChatListUIKitCoordinator { + var isSearchActive: Binding + var isDetailPresented: Binding + + init(isSearchActive: Binding, isDetailPresented: Binding) { + self.isSearchActive = isSearchActive + self.isDetailPresented = isDetailPresented + } + + func setSearchActive(_ active: Bool) { + DispatchQueue.main.async { + if self.isSearchActive.wrappedValue != active { + self.isSearchActive.wrappedValue = active + } + } + } + + func setDetailPresented(_ presented: Bool) { + DispatchQueue.main.async { + if self.isDetailPresented.wrappedValue != presented { + self.isDetailPresented.wrappedValue = presented + } + } + } +} + +@MainActor +final class ChatListRootViewController: UIViewController, UINavigationControllerDelegate { + + var onSearchActiveChanged: ((Bool) -> Void)? + var onDetailPresentedChanged: ((Bool) -> Void)? + + private let viewModel = ChatListViewModel() + private let listController = ChatListCollectionController() + private let searchHeaderView = ChatListSearchHeaderView() + private let toolbarTitleView = ChatListToolbarTitleView() + private let navigationBlurView = ChatListHeaderBlurView() + private var typingDialogs: [String: Set] = [:] + private var currentSearchQuery = "" + private var searchResultUsersByKey: [String: SearchUser] = [:] + private let searchTopSpacing: CGFloat = 5 + private let searchBottomSpacing: CGFloat = 5 + private let searchHeaderHeight: CGFloat = 44 + private var searchChromeHeight: CGFloat { + searchTopSpacing + searchHeaderHeight + searchBottomSpacing + } + + private var searchHeaderTopConstraint: NSLayoutConstraint? + private var searchHeaderHeightConstraint: NSLayoutConstraint? + private var navigationBlurHeightConstraint: NSLayoutConstraint? + private var lastSearchExpansion: CGFloat = 1.0 + private var lastNavigationBlurProgress: CGFloat = -1.0 + private var lastNavigationBlurSearchActive = false + private var lastNavigationBlurPinnedFraction: CGFloat = -1.0 + private var hasPinnedDialogs = false + private var pinnedHeaderFraction: CGFloat = 0.0 + + private var typingDialogsCancellable: AnyCancellable? + private var searchResultsCancellable: AnyCancellable? + private var observationTask: Task? + private var openChatObserver: NSObjectProtocol? + private var didBecomeActiveObserver: NSObjectProtocol? + + private lazy var editButtonControl: ChatListToolbarEditButton = { + let button = ChatListToolbarEditButton() + button.addTarget(self, action: #selector(editTapped), for: .touchUpInside) + return button + }() + + private lazy var rightButtonsControl: ChatListToolbarDualActionButton = { + let button = ChatListToolbarDualActionButton() + button.onAddPressed = { [weak self] in + self?.addPressed() + } + button.onComposePressed = { [weak self] in + self?.composeTapped() + } + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + + setupNavigationChrome() + setupSearchHeader() + setupListController() + setupObservers() + startObservationLoop() + render() + consumePendingRouteIfFresh() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if navigationController?.delegate !== self { + navigationController?.delegate = self + } + // Hide nav bar on chat list; pushed VCs show it via willShow delegate + navigationController?.setNavigationBarHidden(true, animated: animated) + let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion) + updateNavigationBarBlur(progress: blurProgress) + onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomHeader() + updateNavigationBlurHeight() + } + + private func layoutCustomHeader() { + let statusBarHeight = view.safeAreaInsets.top + let centerY = statusBarHeight + headerBarHeight * 0.5 + let sideInset: CGFloat = 16 + + // Edit button (left) + let editSize = editButtonControl.intrinsicContentSize + editButtonControl.frame = CGRect( + x: sideInset, + y: centerY - editSize.height * 0.5, + width: editSize.width, + height: editSize.height + ) + + // Right buttons (right) + let rightSize = rightButtonsControl.intrinsicContentSize + rightButtonsControl.frame = CGRect( + x: view.bounds.width - sideInset - rightSize.width, + y: centerY - rightSize.height * 0.5, + width: rightSize.width, + height: rightSize.height + ) + + // Title (centered) + let titleLeft = editButtonControl.frame.maxX + 8 + let titleRight = rightButtonsControl.frame.minX - 8 + let titleWidth = max(0, titleRight - titleLeft) + let titleSize = toolbarTitleView.intrinsicContentSize + let actualWidth = min(titleSize.width, titleWidth) + toolbarTitleView.frame = CGRect( + x: titleLeft + (titleWidth - actualWidth) * 0.5, + y: centerY - 15, + width: actualWidth, + height: 30 + ) + } + + private func updateNavigationBlurHeight() { + let headerBottom = headerTotalHeight + let expandedSearchHeight = searchChromeHeight * lastSearchExpansion + navigationBlurHeightConstraint?.constant = max(0, headerBottom + expandedSearchHeight + 14.0) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Keep tab bar state in sync when leaving the screen while search is active. + if searchHeaderView.isSearchActive { + searchHeaderView.endSearch(animated: false, clearText: true) + } + } + + deinit { + observationTask?.cancel() + if let openChatObserver { + NotificationCenter.default.removeObserver(openChatObserver) + } + if let didBecomeActiveObserver { + NotificationCenter.default.removeObserver(didBecomeActiveObserver) + } + } + + /// Height of the custom header bar content (matches UINavigationBar standard) + private let headerBarHeight: CGFloat = 44 + /// Computed total header height: status bar + header bar + private var headerTotalHeight: CGFloat { + view.safeAreaInsets.top + headerBarHeight + } + + private func setupNavigationChrome() { + definesPresentationContext = true + + // Blur background layer (below buttons) + navigationBlurView.translatesAutoresizingMaskIntoConstraints = false + navigationBlurView.isUserInteractionEnabled = false + navigationBlurView.layer.zPosition = 40 + view.addSubview(navigationBlurView) + + let blurHeight = navigationBlurView.heightAnchor.constraint(equalToConstant: 0) + navigationBlurHeightConstraint = blurHeight + NSLayoutConstraint.activate([ + navigationBlurView.topAnchor.constraint(equalTo: view.topAnchor), + navigationBlurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBlurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + blurHeight, + ]) + + // Header elements as direct subviews (z=55, above blur at z=40 and search at z=50) + for v: UIView in [editButtonControl, rightButtonsControl, toolbarTitleView] { + v.layer.zPosition = 55 + view.addSubview(v) + } + + toolbarTitleView.addTarget(self, action: #selector(handleTitleTapped), for: .touchUpInside) + updateNavigationBarBlur(progress: 0) + } + + @objc private func handleTitleTapped() { + NotificationCenter.default.post(name: .chatListScrollToTop, object: nil) + } + + private func setupSearchHeader() { + searchHeaderView.translatesAutoresizingMaskIntoConstraints = false + searchHeaderView.clipsToBounds = true + searchHeaderView.layer.zPosition = 50 + view.addSubview(searchHeaderView) + + // With nav bar hidden, safeArea.top = status bar only. + // Search bar goes below our custom 44pt header bar. + let top = searchHeaderView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: headerBarHeight + searchTopSpacing) + let height = searchHeaderView.heightAnchor.constraint(equalToConstant: searchHeaderHeight) + searchHeaderTopConstraint = top + searchHeaderHeightConstraint = height + + NSLayoutConstraint.activate([ + top, + searchHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + searchHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + height, + ]) + + searchHeaderView.onQueryChanged = { [weak self] query in + guard let self else { return } + self.currentSearchQuery = query + self.viewModel.setSearchQuery(query) + self.renderList() + } + + searchHeaderView.onActiveChanged = { [weak self] active in + guard let self else { return } + self.applySearchExpansion(1.0, animated: true) + self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion)) + self.onSearchActiveChanged?(active) + } + } + + private func setupListController() { + addChild(listController) + view.insertSubview(listController.view, belowSubview: searchHeaderView) + listController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + listController.view.topAnchor.constraint(equalTo: view.topAnchor), + listController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + listController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + listController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + listController.didMove(toParent: self) + listController.setSearchHeaderExpansion(lastSearchExpansion) + + listController.onSelectDialog = { [weak self] dialog in + guard let self else { return } + + let selectedUser = self.searchResultUsersByKey[dialog.opponentKey] + let shouldOpenUserRoute = selectedUser != nil && !self.currentSearchQuery.isEmpty + let routeToOpen: ChatRoute + if let user = selectedUser, shouldOpenUserRoute { + self.viewModel.addToRecent(user) + routeToOpen = ChatRoute(user: user) + } else { + routeToOpen = ChatRoute(dialog: dialog) + } + + if self.searchHeaderView.isSearchActive { + self.searchHeaderView.endSearch(animated: false, clearText: true) + // Let keyboard dismissal complete before starting the push animation. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + self?.openChat(route: routeToOpen) + } + return + } + + self.openChat(route: routeToOpen) + } + listController.onDeleteDialog = { [weak self] dialog in + self?.viewModel.deleteDialog(dialog) + } + listController.onTogglePin = { [weak self] dialog in + guard let self else { return } + self.viewModel.togglePin(dialog) + self.renderList() + let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion) + self.updateNavigationBarBlur(progress: progress, force: true) + } + listController.onToggleMute = { [weak self] dialog in + self?.viewModel.toggleMute(dialog) + } + listController.onPinnedStateChange = { [weak self] hasPinned in + guard let self else { return } + self.hasPinnedDialogs = hasPinned + if !hasPinned { + self.pinnedHeaderFraction = 0.0 + } + let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion) + self.updateNavigationBarBlur(progress: progress, force: true) + } + listController.onPinnedHeaderFractionChange = { [weak self] fraction in + guard let self else { return } + self.pinnedHeaderFraction = self.hasPinnedDialogs ? fraction : 0.0 + let progress = self.searchHeaderView.isSearchActive ? 1.0 : (1.0 - self.lastSearchExpansion) + self.updateNavigationBarBlur(progress: progress) + } + listController.onMarkAsRead = { [weak self] dialog in + self?.viewModel.markAsRead(dialog) + } + listController.onShowRequests = { [weak self] in + self?.openRequests() + } + listController.onScrollOffsetChange = { [weak self] expansion in + self?.applySearchExpansion(expansion, animated: false) + } + } + + private func setupObservers() { + typingDialogsCancellable = MessageRepository.shared.$typingDialogs + .receive(on: DispatchQueue.main) + .sink { [weak self] typing in + guard let self else { return } + self.typingDialogs = typing + self.renderList() + } + + searchResultsCancellable = viewModel.$serverSearchResults + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.renderList() + } + + openChatObserver = NotificationCenter.default.addObserver( + forName: .openChatFromNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let route = notification.object as? ChatRoute else { return } + AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = nil + Task { @MainActor [weak self] in + self?.openChat(route: route) + } + } + + didBecomeActiveObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.consumePendingRouteIfFresh() + } + } + } + + private func startObservationLoop() { + observationTask?.cancel() + observationTask = Task { @MainActor [weak self] in + self?.observeState() + } + } + + private func observeState() { + withObservationTracking { + _ = DialogRepository.shared.dialogs + _ = SessionManager.shared.syncBatchInProgress + _ = ProtocolManager.shared.connectionState + _ = AvatarRepository.shared.avatarVersion + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.render() + self.observeState() + } + } + } + + private func render() { + updateNavigationTitle() + renderList() + } + + private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) { + let clamped = max(0.0, min(1.0, expansion)) + if searchHeaderView.isSearchActive && clamped < 0.999 { + return + } + guard abs(clamped - lastSearchExpansion) > 0.003 else { return } + lastSearchExpansion = clamped + + // Telegram non-linear: search bar stays fully visible for first 27% of scroll + let visibleProgress = max(0.0, min(1.0, (clamped - 0.267) / 0.733)) + + // Structural: use raw clamped + searchHeaderTopConstraint?.constant = searchTopSpacing * clamped + searchHeaderHeightConstraint?.constant = searchHeaderHeight * clamped + listController.setSearchHeaderExpansion(clamped) + + // Visual: use non-linear visibleProgress + searchHeaderView.alpha = visibleProgress + searchHeaderView.isUserInteractionEnabled = visibleProgress > 0.2 + let yShift = -8.0 * (1.0 - clamped) + searchHeaderView.transform = CGAffineTransform(translationX: 0, y: yShift) + .scaledBy(x: 1.0, y: 0.92 + 0.08 * visibleProgress) + + updateNavigationBlurHeight() + updateNavigationBarBlur(progress: 1.0 - clamped) + + let updates = { self.view.layoutIfNeeded() } + if animated { + UIView.animate( + withDuration: 0.16, + delay: 0, + options: [.curveEaseInOut, .beginFromCurrentState], + animations: updates + ) + } else { + updates() + } + } + + private func updateNavigationBarBlur(progress: CGFloat, force: Bool = false) { + let clamped = max(0.0, min(1.0, progress)) + let isSearchActive = searchHeaderView.isSearchActive + let effectivePinnedFraction = isSearchActive ? 0.0 : pinnedHeaderFraction + + let hasChanged = abs(clamped - lastNavigationBlurProgress) > 0.01 + || isSearchActive != lastNavigationBlurSearchActive + || abs(effectivePinnedFraction - lastNavigationBlurPinnedFraction) > 0.01 + guard force || hasChanged else { return } + + lastNavigationBlurProgress = clamped + lastNavigationBlurSearchActive = isSearchActive + lastNavigationBlurPinnedFraction = effectivePinnedFraction + + navigationBlurView.setProgress( + clamped, + pinnedFraction: effectivePinnedFraction, + isSearchActive: isSearchActive + ) + } + + private func updateNavigationTitle() { + let state = ProtocolManager.shared.connectionState + let isSyncing = SessionManager.shared.syncBatchInProgress + + let publicKey = AccountManager.shared.currentAccount?.publicKey ?? SessionManager.shared.currentPublicKey + let displayName = SessionManager.shared.displayName + let initials = RosettaColors.initials(name: displayName, publicKey: publicKey) + let avatarIndex = RosettaColors.avatarColorIndex(for: displayName, publicKey: publicKey) + let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: publicKey) + + if state == .authenticated && isSyncing { + toolbarTitleView.configure( + title: "Updating...", + mode: .loading, + initials: initials, + avatarIndex: avatarIndex, + avatarImage: avatarImage + ) + } else if state == .authenticated { + toolbarTitleView.configure( + title: "Chats", + mode: .avatar, + initials: initials, + avatarIndex: avatarIndex, + avatarImage: avatarImage + ) + } else { + toolbarTitleView.configure( + title: "Connecting...", + mode: .loading, + initials: initials, + avatarIndex: avatarIndex, + avatarImage: avatarImage + ) + } + } + + private func renderList() { + let dialogs = dialogsForCurrentSearchState() + let pinned = dialogs.filter(\.isPinned) + let unpinned = dialogs.filter { !$0.isPinned } + let requestsCount = currentSearchQuery.isEmpty ? viewModel.requestsCount : 0 + + hasPinnedDialogs = !pinned.isEmpty + if !hasPinnedDialogs { + pinnedHeaderFraction = 0.0 + } + + listController.updateDialogs( + pinned: pinned, + unpinned: unpinned, + requestsCount: requestsCount, + typingDialogs: typingDialogs, + isSyncing: SessionManager.shared.syncBatchInProgress + ) + } + + private func dialogsForCurrentSearchState() -> [Dialog] { + searchResultUsersByKey = [:] + + let all = viewModel.allModeDialogs + let query = currentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return all } + + if !viewModel.serverSearchResults.isEmpty { + let account = SessionManager.shared.currentPublicKey + let dialogs = viewModel.serverSearchResults.map { user in + searchResultUsersByKey[user.publicKey] = user + return Dialog( + id: user.publicKey, + account: account, + opponentKey: user.publicKey, + opponentTitle: user.title, + opponentUsername: user.username, + lastMessage: "", + lastMessageTimestamp: 0, + unreadCount: 0, + isOnline: user.online == 0, + lastSeen: 0, + verified: user.verified, + iHaveSent: true, + isPinned: false, + isMuted: false, + lastMessageFromMe: false, + lastMessageDelivered: .delivered, + lastMessageRead: false + ) + } + return dialogs + } + + let normalized = query.lowercased() + return all.filter { dialog in + dialog.opponentTitle.lowercased().contains(normalized) + || dialog.opponentUsername.lowercased().contains(normalized) + || dialog.opponentKey.lowercased().contains(normalized) + } + } + + private func consumePendingRouteIfFresh() { + guard let route = AppDelegate.consumeFreshPendingRoute() else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.openChat(route: route) + } + } + + private func openChat(route: ChatRoute) { + let detail = ChatDetailView( + route: route, + onPresentedChange: { [weak self] presented in + self?.onDetailPresentedChanged?(presented) + } + ) + + let hosting = UIHostingController(rootView: detail.id(route.publicKey)) + hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + navigationController?.pushViewController(hosting, animated: true) + } + + private func openRequests() { + let vc = RequestChatsUIKitShellController(viewModel: viewModel) + vc.onOpenRoute = { [weak self] route in + self?.openChat(route: route) + } + navigationController?.pushViewController(vc, animated: true) + } + + @objc private func editTapped() { + // UIKit shell parity: keep action reserved for future editing mode. + } + + @objc private func addPressed() { + composeTapped() + } + + @objc private func composeTapped() { + let sheet = UIAlertController(title: "New", message: nil, preferredStyle: .actionSheet) + + sheet.addAction(UIAlertAction(title: "New Group", style: .default, handler: { [weak self] _ in + self?.presentGroupSetup() + })) + + sheet.addAction(UIAlertAction(title: "Join Group", style: .default, handler: { [weak self] _ in + self?.presentGroupJoin() + })) + + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + if let popover = sheet.popoverPresentationController { + popover.sourceView = rightButtonsControl + popover.sourceRect = rightButtonsControl.bounds + popover.permittedArrowDirections = .up + } + + present(sheet, animated: true) + } + + private func presentGroupSetup() { + let root = NavigationStack { + GroupSetupView { [weak self] route in + guard let self else { return } + self.dismiss(animated: true) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.openChat(route: route) + } + } + } + } + + let host = UIHostingController(rootView: root) + host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + let nav = UINavigationController(rootViewController: host) + nav.modalPresentationStyle = .pageSheet + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + } + present(nav, animated: true) + } + + private func presentGroupJoin() { + let root = NavigationStack { + GroupJoinView { [weak self] route in + guard let self else { return } + self.dismiss(animated: true) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.openChat(route: route) + } + } + } + } + + let host = UIHostingController(rootView: root) + host.view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + let nav = UINavigationController(rootViewController: host) + nav.modalPresentationStyle = .pageSheet + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + } + present(nav, animated: true) + } + + // MARK: - UINavigationControllerDelegate + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + // Show standard nav bar for pushed screens, hide on chat list + let isChatList = viewController === self + navigationController.setNavigationBarHidden(isChatList, animated: animated) + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + let isPresented = navigationController.viewControllers.count > 1 + onDetailPresentedChanged?(isPresented) + } +} + +@MainActor +final class RequestChatsUIKitShellController: UIViewController { + + var onOpenRoute: ((ChatRoute) -> Void)? + + private let viewModel: ChatListViewModel + private let requestsController = RequestChatsController() + private var observationTask: Task? + + init(viewModel: ChatListViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(RosettaColors.Adaptive.background) + title = "Request Chats" + + addChild(requestsController) + view.addSubview(requestsController.view) + requestsController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + requestsController.view.topAnchor.constraint(equalTo: view.topAnchor), + requestsController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + requestsController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + requestsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + requestsController.didMove(toParent: self) + + requestsController.onSelectDialog = { [weak self] dialog in + self?.onOpenRoute?(ChatRoute(dialog: dialog)) + } + requestsController.onDeleteDialog = { [weak self] dialog in + self?.viewModel.deleteDialog(dialog) + } + + startObservationLoop() + render() + } + + deinit { + observationTask?.cancel() + } + + private func startObservationLoop() { + observationTask?.cancel() + observationTask = Task { @MainActor [weak self] in + self?.observeState() + } + } + + private func observeState() { + withObservationTracking { + _ = DialogRepository.shared.dialogs + _ = SessionManager.shared.syncBatchInProgress + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.render() + self.observeState() + } + } + } + + private func render() { + requestsController.updateDialogs( + viewModel.requestsModeDialogs, + isSyncing: SessionManager.shared.syncBatchInProgress + ) + } +} + +private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate { + + var onQueryChanged: ((String) -> Void)? + var onActiveChanged: ((Bool) -> Void)? + + private(set) var isSearchActive = false + + private let capsuleView = UIView() + private let placeholderStack = UIStackView() + private let placeholderIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + private let placeholderLabel = UILabel() + + private let activeStack = UIStackView() + private let activeIcon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + private let textField = UITextField() + private let inlineClearButton = UIButton(type: .system) + private let cancelButton = UIButton(type: .system) + + private var cancelWidthConstraint: NSLayoutConstraint! + private var suppressQueryCallback = false + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + applyColors() + updateVisualState(animated: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyColors() + } + + func endSearch(animated: Bool, clearText: Bool) { + setSearchActive(false, animated: animated, clearText: clearText) + } + + private func setupUI() { + translatesAutoresizingMaskIntoConstraints = false + + capsuleView.translatesAutoresizingMaskIntoConstraints = false + capsuleView.layer.cornerRadius = 22 + capsuleView.layer.borderWidth = 0.5 + capsuleView.clipsToBounds = true + addSubview(capsuleView) + + placeholderStack.translatesAutoresizingMaskIntoConstraints = false + placeholderStack.axis = .horizontal + placeholderStack.alignment = .center + placeholderStack.spacing = 4 + placeholderIcon.contentMode = .scaleAspectFit + placeholderIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + placeholderLabel.text = "Search" + placeholderLabel.font = .systemFont(ofSize: 17) + placeholderStack.addArrangedSubview(placeholderIcon) + placeholderStack.addArrangedSubview(placeholderLabel) + capsuleView.addSubview(placeholderStack) + + activeStack.translatesAutoresizingMaskIntoConstraints = false + activeStack.axis = .horizontal + activeStack.alignment = .center + activeStack.spacing = 2 + activeIcon.contentMode = .scaleAspectFit + activeIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + + textField.translatesAutoresizingMaskIntoConstraints = false + textField.placeholder = "Search" + textField.font = .systemFont(ofSize: 17) + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.returnKeyType = .search + textField.clearButtonMode = .never + textField.delegate = self + textField.addTarget(self, action: #selector(handleTextChanged), for: .editingChanged) + + inlineClearButton.translatesAutoresizingMaskIntoConstraints = false + inlineClearButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + inlineClearButton.addTarget(self, action: #selector(handleInlineClearTapped), for: .touchUpInside) + + activeStack.addArrangedSubview(activeIcon) + activeStack.addArrangedSubview(textField) + activeStack.addArrangedSubview(inlineClearButton) + capsuleView.addSubview(activeStack) + + cancelButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.setTitle("Cancel", for: .normal) + cancelButton.titleLabel?.font = .systemFont(ofSize: 17) + cancelButton.contentHorizontalAlignment = .right + cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside) + addSubview(cancelButton) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleCapsuleTapped)) + capsuleView.addGestureRecognizer(tap) + + cancelWidthConstraint = cancelButton.widthAnchor.constraint(equalToConstant: 0) + + NSLayoutConstraint.activate([ + capsuleView.leadingAnchor.constraint(equalTo: leadingAnchor), + capsuleView.topAnchor.constraint(equalTo: topAnchor), + capsuleView.bottomAnchor.constraint(equalTo: bottomAnchor), + capsuleView.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -8), + + cancelButton.trailingAnchor.constraint(equalTo: trailingAnchor), + cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor), + cancelWidthConstraint, + + placeholderStack.centerXAnchor.constraint(equalTo: capsuleView.centerXAnchor), + placeholderStack.centerYAnchor.constraint(equalTo: capsuleView.centerYAnchor), + + activeStack.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: 12), + activeStack.trailingAnchor.constraint(equalTo: capsuleView.trailingAnchor, constant: -10), + activeStack.topAnchor.constraint(equalTo: capsuleView.topAnchor), + activeStack.bottomAnchor.constraint(equalTo: capsuleView.bottomAnchor), + + inlineClearButton.widthAnchor.constraint(equalToConstant: 24), + inlineClearButton.heightAnchor.constraint(equalToConstant: 24), + ]) + } + + private func applyColors() { + capsuleView.backgroundColor = UIColor(RosettaColors.Adaptive.searchBarFill) + capsuleView.layer.borderColor = UIColor(RosettaColors.Adaptive.searchBarBorder).cgColor + placeholderLabel.textColor = .gray + placeholderIcon.tintColor = .gray + activeIcon.tintColor = .gray + textField.textColor = UIColor(RosettaColors.Adaptive.text) + inlineClearButton.tintColor = .gray + cancelButton.setTitleColor(UIColor(RosettaColors.primaryBlue), for: .normal) + } + + private func updateVisualState(animated: Bool) { + let updates = { + self.placeholderStack.alpha = self.isSearchActive ? 0 : 1 + self.activeStack.alpha = self.isSearchActive ? 1 : 0 + self.cancelButton.alpha = self.isSearchActive ? 1 : 0 + self.cancelWidthConstraint.constant = self.isSearchActive ? 64 : 0 + self.layoutIfNeeded() + } + + if animated { + UIView.animate(withDuration: 0.16, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState], animations: updates) + } else { + updates() + } + } + + private func setSearchActive(_ active: Bool, animated: Bool, clearText: Bool = false) { + let oldValue = isSearchActive + isSearchActive = active + + if !active { + textField.resignFirstResponder() + if clearText { + setQueryText("") + } + } else { + DispatchQueue.main.async { [weak self] in + self?.textField.becomeFirstResponder() + } + } + + if oldValue != active { + onActiveChanged?(active) + } + + updateClearButtonVisibility() + updateVisualState(animated: animated) + } + + private func setQueryText(_ text: String) { + let old = textField.text ?? "" + guard old != text else { return } + suppressQueryCallback = true + textField.text = text + suppressQueryCallback = false + updateClearButtonVisibility() + onQueryChanged?(text) + } + + private func updateClearButtonVisibility() { + inlineClearButton.alpha = (textField.text?.isEmpty == false && isSearchActive) ? 1 : 0 + inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0 + } + + @objc private func handleCapsuleTapped() { + guard !isSearchActive else { return } + setSearchActive(true, animated: true) + } + + @objc private func handleTextChanged() { + updateClearButtonVisibility() + guard !suppressQueryCallback else { return } + onQueryChanged?(textField.text ?? "") + } + + @objc private func handleInlineClearTapped() { + setQueryText("") + } + + @objc private func handleCancelTapped() { + endSearch(animated: true, clearText: true) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +private final class ChatListHeaderBlurView: UIView { + + private let edgeEffectView = UIView() + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + private let fadeMaskLayer = CAGradientLayer() + private var plainBackgroundColor: UIColor = .black + private var pinnedBackgroundColor: UIColor = .black + private var currentProgress: CGFloat = 0.0 + private var currentPinnedFraction: CGFloat = 0.0 + private var isSearchCurrentlyActive = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + clipsToBounds = true + + edgeEffectView.translatesAutoresizingMaskIntoConstraints = false + blurView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(edgeEffectView) + addSubview(blurView) + + NSLayoutConstraint.activate([ + edgeEffectView.topAnchor.constraint(equalTo: topAnchor), + edgeEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + edgeEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + edgeEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + layer.mask = fadeMaskLayer + applyAdaptiveColors() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } + applyAdaptiveColors() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateFadeMask() + } + + private func applyAdaptiveColors() { + plainBackgroundColor = UIColor(RosettaColors.Adaptive.background) + pinnedBackgroundColor = UIColor(RosettaColors.Adaptive.pinnedSectionBackground) + blurView.effect = UIBlurEffect(style: .light) + configureTelegramBlurFilters() + updateEdgeEffectColor() + updateChromeOpacity() + } + + private func updateEdgeEffectColor() { + let effectivePinnedFraction = isSearchCurrentlyActive ? 0.0 : currentPinnedFraction + let resolved = plainBackgroundColor.mixedWith(pinnedBackgroundColor, alpha: effectivePinnedFraction) + edgeEffectView.backgroundColor = resolved + } + + private func updateChromeOpacity() { + let clamped = max(0.0, min(1.0, currentProgress)) + edgeEffectView.alpha = clamped + blurView.alpha = 0.85 * clamped + } + + private func configureTelegramBlurFilters() { + guard let sublayer = blurView.layer.sublayers?.first, + let filters = sublayer.filters else { return } + sublayer.backgroundColor = nil + sublayer.isOpaque = false + let allowedKeys: Set = ["gaussianBlur", "colorSaturate"] + sublayer.filters = filters.filter { filter in + guard let obj = filter as? NSObject else { return true } + return allowedKeys.contains(String(describing: obj)) + } + } + + private func updateFadeMask() { + let height = max(1, bounds.height) + let fadeHeight = min(54.0, height) + let fadeStart = max(0.0, (height - fadeHeight) / height) + fadeMaskLayer.frame = bounds + fadeMaskLayer.startPoint = CGPoint(x: 0.5, y: 0) + fadeMaskLayer.endPoint = CGPoint(x: 0.5, y: 1) + fadeMaskLayer.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor] + fadeMaskLayer.locations = [0, NSNumber(value: Float(fadeStart)), 1] + } + + func setProgress(_ progress: CGFloat, pinnedFraction: CGFloat, isSearchActive: Bool) { + currentProgress = max(0.0, min(1.0, progress)) + currentPinnedFraction = max(0.0, min(1.0, pinnedFraction)) + isSearchCurrentlyActive = isSearchActive + updateEdgeEffectColor() + updateChromeOpacity() + } +} + +private final class ChatListToolbarGlassCapsuleView: UIView { + + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterialDark)) + private let tintView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + clipsToBounds = true + + blurView.translatesAutoresizingMaskIntoConstraints = false + tintView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurView) + addSubview(tintView) + + NSLayoutConstraint.activate([ + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + + tintView.topAnchor.constraint(equalTo: topAnchor), + tintView.leadingAnchor.constraint(equalTo: leadingAnchor), + tintView.trailingAnchor.constraint(equalTo: trailingAnchor), + tintView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + layer.borderWidth = 1.0 / UIScreen.main.scale + applyColors() + configureTelegramBlurFilters() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + applyColors() + layer.cornerRadius = bounds.height * 0.5 + configureTelegramBlurFilters() + } + + private func applyColors() { + let isDark = traitCollection.userInterfaceStyle == .dark + blurView.effect = UIBlurEffect(style: isDark ? .systemChromeMaterialDark : .systemChromeMaterialLight) + blurView.alpha = isDark ? 0.88 : 0.82 + + tintView.backgroundColor = isDark + ? UIColor(white: 0.0, alpha: 0.34) + : UIColor(white: 1.0, alpha: 0.28) + layer.borderColor = isDark + ? UIColor.white.withAlphaComponent(0.12).cgColor + : UIColor.black.withAlphaComponent(0.10).cgColor + } + + private func configureTelegramBlurFilters() { + guard let sublayer = blurView.layer.sublayers?.first, + let filters = sublayer.filters else { return } + sublayer.backgroundColor = nil + sublayer.isOpaque = false + let allowedKeys: Set = ["gaussianBlur", "colorSaturate"] + sublayer.filters = filters.filter { filter in + guard let obj = filter as? NSObject else { return true } + return allowedKeys.contains(String(describing: obj)) + } + } +} + +private final class ChatListToolbarEditButton: UIControl { + + private let backgroundView = ChatListToolbarGlassCapsuleView() + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + isAccessibilityElement = true + accessibilityLabel = "Edit" + accessibilityTraits = .button + + addSubview(backgroundView) + backgroundView.translatesAutoresizingMaskIntoConstraints = false + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.text = "Edit" + titleLabel.font = .systemFont(ofSize: 17, weight: .medium) + titleLabel.textColor = UIColor(RosettaColors.Adaptive.text) + titleLabel.textAlignment = .center + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint(equalToConstant: 44), + ]) + + self.frame = CGRect(origin: .zero, size: intrinsicContentSize) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let textWidth = (titleLabel.text as NSString?)?.size(withAttributes: [.font: titleLabel.font!]).width ?? 0 + let width = max(44.0, ceil(textWidth) + 24.0) + return CGSize(width: width, height: 44.0) + } + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + alpha = 0.6 + } else { + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { + self.alpha = 1.0 + } + } + } + } +} + +private final class ChatListToolbarDualActionButton: UIView { + + var onAddPressed: (() -> Void)? + var onComposePressed: (() -> Void)? + + private let backgroundView = ChatListToolbarGlassCapsuleView() + private let addButton = UIButton(type: .system) + private let composeButton = UIButton(type: .system) + private let dividerView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + + isAccessibilityElement = false + + backgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(backgroundView) + + addButton.translatesAutoresizingMaskIntoConstraints = false + composeButton.translatesAutoresizingMaskIntoConstraints = false + dividerView.translatesAutoresizingMaskIntoConstraints = false + + addButton.tintColor = UIColor(RosettaColors.Adaptive.text) + composeButton.tintColor = UIColor(RosettaColors.Adaptive.text) + addButton.accessibilityLabel = "Add" + composeButton.accessibilityLabel = "Compose" + + let iconConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium) + let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "plus", withConfiguration: iconConfig) + let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate) + ?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig) + addButton.setImage(addIcon, for: .normal) + composeButton.setImage(composeIcon, for: .normal) + + addButton.addTarget(self, action: #selector(handleAddTapped), for: .touchUpInside) + composeButton.addTarget(self, action: #selector(handleComposeTapped), for: .touchUpInside) + addButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown) + composeButton.addTarget(self, action: #selector(handleTouchDown(_:)), for: .touchDown) + addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit]) + composeButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit]) + + dividerView.backgroundColor = UIColor.white.withAlphaComponent(0.16) + + addSubview(addButton) + addSubview(composeButton) + addSubview(dividerView) + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + + addButton.leadingAnchor.constraint(equalTo: leadingAnchor), + addButton.topAnchor.constraint(equalTo: topAnchor), + addButton.bottomAnchor.constraint(equalTo: bottomAnchor), + addButton.widthAnchor.constraint(equalToConstant: 38), + + composeButton.trailingAnchor.constraint(equalTo: trailingAnchor), + composeButton.topAnchor.constraint(equalTo: topAnchor), + composeButton.bottomAnchor.constraint(equalTo: bottomAnchor), + composeButton.widthAnchor.constraint(equalToConstant: 38), + + dividerView.centerXAnchor.constraint(equalTo: centerXAnchor), + dividerView.centerYAnchor.constraint(equalTo: centerYAnchor), + dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale), + dividerView.heightAnchor.constraint(equalToConstant: 20), + + heightAnchor.constraint(equalToConstant: 44), + widthAnchor.constraint(equalToConstant: 76), + ]) + + self.frame = CGRect(origin: .zero, size: intrinsicContentSize) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + CGSize(width: 76, height: 44) + } + + @objc private func handleAddTapped() { + onAddPressed?() + } + + @objc private func handleComposeTapped() { + onComposePressed?() + } + + @objc private func handleTouchDown(_ sender: UIButton) { + sender.alpha = 0.6 + } + + @objc private func handleTouchUp(_ sender: UIButton) { + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { + sender.alpha = 1.0 + } + } +} + +private final class ChatListToolbarTitleView: UIControl { + + enum Mode { + case loading + case avatar + } + + private let stackView = UIStackView() + private let titleLabel = UILabel() + private let avatarContainer = UIView() + private let avatarImageView = UIImageView() + private let avatarInitialsLabel = UILabel() + private let spinner = ChatListToolbarArcSpinnerView() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + let fitting = stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + return CGSize(width: fitting.width, height: 30) + } + + func configure(title: String, mode: Mode, initials: String, avatarIndex: Int, avatarImage: UIImage?) { + titleLabel.text = title + avatarInitialsLabel.text = initials + + let tintColor = RosettaColors.avatarColor(for: avatarIndex) + avatarContainer.backgroundColor = tintColor + + if let avatarImage { + avatarImageView.image = avatarImage + avatarImageView.isHidden = false + avatarInitialsLabel.isHidden = true + } else { + avatarImageView.image = nil + avatarImageView.isHidden = true + avatarInitialsLabel.isHidden = false + } + + switch mode { + case .loading: + spinner.isHidden = false + spinner.startAnimating() + avatarContainer.isHidden = true + case .avatar: + spinner.stopAnimating() + spinner.isHidden = true + avatarContainer.isHidden = false + } + + invalidateIntrinsicContentSize() + } + + private func setupUI() { + isAccessibilityElement = true + accessibilityTraits = .button + + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 6 + addSubview(stackView) + + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.setColor(UIColor(RosettaColors.primaryBlue)) + + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + avatarContainer.clipsToBounds = true + avatarContainer.layer.cornerRadius = 14 + + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + + avatarInitialsLabel.translatesAutoresizingMaskIntoConstraints = false + avatarInitialsLabel.font = .systemFont(ofSize: 12, weight: .semibold) + avatarInitialsLabel.textColor = .white + avatarInitialsLabel.textAlignment = .center + + avatarContainer.addSubview(avatarImageView) + avatarContainer.addSubview(avatarInitialsLabel) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .white + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + stackView.addArrangedSubview(spinner) + stackView.addArrangedSubview(avatarContainer) + stackView.addArrangedSubview(titleLabel) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + spinner.widthAnchor.constraint(equalToConstant: 20), + spinner.heightAnchor.constraint(equalToConstant: 20), + + avatarContainer.widthAnchor.constraint(equalToConstant: 28), + avatarContainer.heightAnchor.constraint(equalToConstant: 28), + + avatarImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor), + avatarImageView.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor), + avatarImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + + avatarInitialsLabel.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor), + avatarInitialsLabel.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor), + avatarInitialsLabel.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatarInitialsLabel.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + ]) + } +} + +private final class ChatListToolbarArcSpinnerView: UIView { + + private let arcLayer = CAShapeLayer() + private let animationKey = "chatlist.toolbar.arcSpinner.rotation" + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + backgroundColor = .clear + + arcLayer.fillColor = UIColor.clear.cgColor + arcLayer.lineWidth = 2 + arcLayer.lineCap = .round + layer.addSublayer(arcLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopAnimating() + } + + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + if newSuperview == nil { + stopAnimating() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + arcLayer.frame = bounds + + let radius = max(0, min(bounds.width, bounds.height) * 0.5 - arcLayer.lineWidth) + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let start = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.05) + let end = -CGFloat.pi / 2 + (2 * CGFloat.pi * 0.78) + arcLayer.path = UIBezierPath( + arcCenter: center, + radius: radius, + startAngle: start, + endAngle: end, + clockwise: true + ).cgPath + } + + func setColor(_ color: UIColor) { + arcLayer.strokeColor = color.cgColor + } + + func startAnimating() { + guard layer.animation(forKey: animationKey) == nil else { return } + let animation = CABasicAnimation(keyPath: "transform.rotation.z") + animation.fromValue = 0 + animation.toValue = Double.pi * 2 + animation.duration = 1.0 + animation.repeatCount = .infinity + animation.timingFunction = CAMediaTimingFunction(name: .linear) + layer.add(animation, forKey: animationKey) + } + + func stopAnimating() { + layer.removeAnimation(forKey: animationKey) + } +} diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift index 421577d..528f377 100644 --- a/Rosetta/Features/Groups/GroupInfoView.swift +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -215,7 +215,7 @@ private extension GroupInfoView { subtitleText: "\(viewModel.memberCount) members", effectiveVerified: 0, avatarImage: groupAvatar, - avatarInitials: RosettaColors.initials(name: viewModel.groupTitle, publicKey: viewModel.groupDialogKey), + avatarInitials: RosettaColors.groupInitial(name: viewModel.groupTitle, publicKey: viewModel.groupDialogKey), avatarColorIndex: RosettaColors.avatarColorIndex(for: viewModel.groupTitle, publicKey: viewModel.groupDialogKey), isMuted: isMuted, showCallButton: false, diff --git a/Rosetta/Features/Groups/GroupJoinView.swift b/Rosetta/Features/Groups/GroupJoinView.swift index eb9cf37..31d0919 100644 --- a/Rosetta/Features/Groups/GroupJoinView.swift +++ b/Rosetta/Features/Groups/GroupJoinView.swift @@ -112,15 +112,13 @@ private extension GroupJoinView { GlassCard(cornerRadius: 16) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { - // Group icon - ZStack { - Circle() - .fill(RosettaColors.figmaBlue.opacity(0.2)) - .frame(width: 48, height: 48) - Image(systemName: "person.2.fill") - .font(.system(size: 20)) - .foregroundStyle(RosettaColors.figmaBlue) - } + // Group avatar (Mantine light variant) + let groupTitle = viewModel.parsedTitle ?? "" + AvatarView( + initials: RosettaColors.groupInitial(name: groupTitle, publicKey: ""), + colorIndex: RosettaColors.avatarColorIndex(for: groupTitle), + size: 48 + ) VStack(alignment: .leading, spacing: 2) { if let title = viewModel.parsedTitle { diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index d52f84f..a12fda4 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -36,13 +36,7 @@ struct MainTabView: View { let _ = PerformanceLogger.shared.track("mainTab.bodyEval") #endif ZStack { - Group { - if #available(iOS 26.0, *) { - systemTabView - } else { - legacyTabView - } - } + legacyTabView .fullScreenCover(item: $addAccountScreen) { screen in AuthCoordinator( onAuthComplete: { @@ -111,37 +105,7 @@ struct MainTabView: View { } } - // MARK: - iOS 26+ (native TabView with liquid glass tab bar) - - @available(iOS 26.0, *) - private var systemTabView: some View { - TabView(selection: $selectedTab) { - CallsView() - .tabItem { - Label(RosettaTab.calls.label, systemImage: RosettaTab.calls.icon) - } - .tag(RosettaTab.calls) - - ChatListView( - isSearchActive: $isChatSearchActive, - isDetailPresented: $isChatListDetailPresented - ) - .tabItem { - Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon) - } - .tag(RosettaTab.chats) - .badge(cachedUnreadCount) - - SettingsContainerView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) - .tabItem { - Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) - } - .tag(RosettaTab.settings) - } - .tint(RosettaColors.primaryBlue) - } - - // MARK: - iOS < 26 (custom RosettaTabBar with pager) + // MARK: - Tab View (custom RosettaTabBar with pager) private var legacyTabView: some View { ZStack(alignment: .bottom) { @@ -209,7 +173,7 @@ struct MainTabView: View { if activatedTabs.contains(tab) { switch tab { case .chats: - ChatListView( + ChatListUIKitView( isSearchActive: $isChatSearchActive, isDetailPresented: $isChatListDetailPresented ) @@ -284,7 +248,7 @@ private extension View { /// Non-conditional badge — preserves structural identity. /// A @ViewBuilder `if let / else` creates two branches; switching /// between them changes the child's structural identity, destroying - /// any @StateObject (including ChatListNavigationState.path). + /// any child view identity-dependent state. func badgeIfNeeded(_ value: String?) -> some View { badge(Text(value ?? "")) } diff --git a/Rosetta/Features/Settings/BackupView.swift b/Rosetta/Features/Settings/BackupView.swift index 96c453d..700090e 100644 --- a/Rosetta/Features/Settings/BackupView.swift +++ b/Rosetta/Features/Settings/BackupView.swift @@ -314,20 +314,15 @@ private struct BackupSeedCardStyle: ViewModifier { let color: Color func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) - } else { - content - .background { - RoundedRectangle(cornerRadius: 12) - .fill(color.opacity(0.12)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(color.opacity(0.18), lineWidth: 0.5) - } - } - .clipShape(RoundedRectangle(cornerRadius: 12)) - } + content + .background { + RoundedRectangle(cornerRadius: 12) + .fill(color.opacity(0.12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.18), lineWidth: 0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12)) } } diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 26a5c49..a7f5fda 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -805,6 +805,9 @@ struct RosettaApp: App { traits.userInterfaceStyle == .dark ? .black : .white } + // Suppress iOS 26 Liquid Glass on navigation bars — unified look across all iOS versions. + UINavigationBar.appearance().preferredBehavioralStyle = .pad + // Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not. // If this is the first launch after install, clear any stale Keychain data. if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") { diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index fdb88eb..7886a70 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -446,8 +446,17 @@ final class NotificationService: UNNotificationServiceExtension { return Int(index) } + /// Single-letter initial for group avatars. + /// Copy of RosettaColors.groupInitial(name:publicKey:) from Colors.swift. + private static func groupInitial(name: String, publicKey: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespaces) + if let first = trimmed.first { return String(first).uppercased() } + if !publicKey.isEmpty { return String(publicKey.prefix(1)).uppercased() } + return "?" + } + /// Desktop parity: 2-letter initials from display name. - /// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift:209-223. + /// Exact copy of RosettaColors.initials(name:publicKey:) from Colors.swift. private static func initials(name: String, publicKey: String) -> String { let words = name.trimmingCharacters(in: .whitespaces) .split(whereSeparator: { $0.isWhitespace }) @@ -525,16 +534,15 @@ final class NotificationService: UNNotificationServiceExtension { return image } - /// Generates a 50x50 group avatar with person.2.fill icon on solid tint circle. - /// Matches ChatRowView.swift:99-106 (group avatar without photo). + /// Generates a 50x50 group avatar with single-letter initial on Mantine light circle. private static func generateGroupAvatar(name: String, key: String) -> INImage? { let size: CGFloat = 50 let colorIndex = avatarColorIndex(for: name, publicKey: key) let colors = avatarColors[colorIndex] + let text = groupInitial(name: name, publicKey: key) - // Try 2.0 scale first; fallback to 1.0 if NSE memory is constrained. - let image = renderGroupAvatar(size: size, tintHex: colors.tint, scale: 2.0) - ?? renderGroupAvatar(size: size, tintHex: colors.tint, scale: 1.0) + let image = renderLetterAvatar(size: size, colors: colors, text: text, scale: 2.0) + ?? renderLetterAvatar(size: size, colors: colors, text: text, scale: 1.0) guard let pngData = image?.pngData() else { return nil } if let tempURL = storeTemporaryImage(data: pngData, key: "group-\(key)", fileExtension: "png") { @@ -543,33 +551,6 @@ final class NotificationService: UNNotificationServiceExtension { return INImage(imageData: pngData) } - /// Renders group avatar at given scale. Returns nil if UIGraphics context can't be allocated. - private static func renderGroupAvatar(size: CGFloat, tintHex: UInt32, scale: CGFloat) -> UIImage? { - UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, scale) - guard UIGraphicsGetCurrentContext() != nil else { return nil } - - let rect = CGRect(x: 0, y: 0, width: size, height: size) - uiColor(hex: tintHex).setFill() - UIBezierPath(ovalIn: rect).fill() - - 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() - return image - } - /// Loads sender avatar from shared App Group cache written by the main app. /// Falls back to letter avatar when no real image is available. private static func loadNotificationAvatar(for senderKey: String) -> INImage? { diff --git a/RosettaTests/ChatListBottomInsetTests.swift b/RosettaTests/ChatListBottomInsetTests.swift index b56b299..1c3581c 100644 --- a/RosettaTests/ChatListBottomInsetTests.swift +++ b/RosettaTests/ChatListBottomInsetTests.swift @@ -45,8 +45,8 @@ final class ChatListBottomInsetTests: XCTestCase { ) } - /// Test 2: Verify automatic safe area adjustment is enabled - func testContentInsetAdjustmentBehaviorIsAutomatic() { + /// Test 2: Verify manual inset mode is enabled (UIKit auto-adjust disabled). + func testContentInsetAdjustmentBehaviorIsNever() { _ = controller.view let collectionView = controller.value(forKey: "collectionView") as? UICollectionView @@ -54,8 +54,8 @@ final class ChatListBottomInsetTests: XCTestCase { XCTAssertEqual( collectionView?.contentInsetAdjustmentBehavior, - .automatic, - "Should use automatic adjustment (respects tab bar safe area)" + .never, + "Should use manual inset mode for custom tab-bar safe area handling" ) } diff --git a/RosettaTests/ReplyPreviewTextTests.swift b/RosettaTests/ReplyPreviewTextTests.swift index 49c0569..2adf115 100644 --- a/RosettaTests/ReplyPreviewTextTests.swift +++ b/RosettaTests/ReplyPreviewTextTests.swift @@ -19,35 +19,90 @@ final class ReplyPreviewTextTests: XCTestCase { /// Standalone replica of `ChatDetailView.replyPreviewText(for:)`. /// Must match the real implementation exactly. private func replyPreviewText(for message: ChatMessage) -> String { - if message.attachments.contains(where: { $0.type == .image }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - return caption.isEmpty ? "Photo" : caption - } - if let file = message.attachments.first(where: { $0.type == .file }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !caption.isEmpty { return caption } - let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) - if !parsed.fileName.isEmpty { return parsed.fileName } - return file.id.isEmpty ? "File" : file.id - } - if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } - if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } - if message.attachments.contains(where: { $0.type == .call }) { return "Call" } - let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return message.text } - if !message.attachments.isEmpty { return "Attachment" } + let attachmentLabel: String? = { + for att in message.attachments { + switch att.type { + case .image: + return "Photo" + case .file: + let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } + return att.id.isEmpty ? "File" : att.id + case .avatar: + return "Avatar" + case .messages: + return "Forwarded message" + case .call: + return "Call" + case .voice: + return "Voice message" + } + } + return nil + }() + + let visibleText: String = { + let stripped = message.text + .trimmingCharacters(in: .whitespacesAndNewlines) + .filter { !$0.isASCII || $0.asciiValue! >= 0x20 } + if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" } + return stripped + }() + let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText) + + if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded } + if let label = attachmentLabel { return label } + if !visibleTextDecoded.isEmpty { return visibleTextDecoded } return "" } + /// Standalone replica of SessionManager in-app banner preview logic. + /// Intentionally mirrors current behavior for regression coverage. + private func inAppBannerPreviewText(text: String, attachments: [MessageAttachment]) -> String { + if !text.isEmpty { return EmojiParser.replaceShortcodes(in: text) } + if let firstAtt = attachments.first { + switch firstAtt.type { + case .image: return "Photo" + case .file: return "File" + case .voice: return "Voice message" + case .avatar: return "Avatar" + case .messages: return "Forwarded message" + case .call: return "Call" + default: return "Attachment" + } + } + return "New message" + } + + /// Standalone replica of NativeMessageList reply text cache logic for UIKit cells. + /// When outer message is empty, it is treated as forwarded wrapper (no quote text shown). + private func nativeReplyCacheText(outerMessageText: String, reply: ReplyMessageData) -> String? { + let displayText = MessageCellLayout.isGarbageOrEncrypted(outerMessageText) ? "" : outerMessageText + guard !displayText.isEmpty else { return nil } + + let trimmed = reply.message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return EmojiParser.replaceShortcodes(in: reply.message) + } + if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } + if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } + if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } + if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } + return "Attachment" + } + /// Standalone replica of `MessageCellView.replyQuoteView` preview logic. /// Uses `ReplyMessageData` + `ReplyAttachmentData` (Int type), not `MessageAttachment`. private func replyQuotePreviewText(for reply: ReplyMessageData) -> String { let trimmed = reply.message.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { return reply.message } + if !trimmed.isEmpty { return EmojiParser.replaceShortcodes(in: reply.message) } if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } + if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" } @@ -63,6 +118,7 @@ final class ReplyPreviewTextTests: XCTestCase { case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" + case .voice: return "Voice message" } } else if textIsEmpty { return "" @@ -195,6 +251,11 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyPreviewText(for: msg), "Avatar") } + func testReplyBar_AvatarWithCaptionUsesCaption() { + let msg = makeMessage(text: "Profile pic", attachments: [makeAttachment(type: .avatar)]) + XCTAssertEqual(replyPreviewText(for: msg), "Profile pic") + } + // MARK: Forward func testReplyBar_Forward() { @@ -202,6 +263,11 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message") } + func testReplyBar_ForwardWithCaptionUsesCaption() { + let msg = makeMessage(text: "See this", attachments: [makeAttachment(type: .messages)]) + XCTAssertEqual(replyPreviewText(for: msg), "See this") + } + // MARK: Call (Android/Desktop parity) func testReplyBar_Call() { @@ -209,6 +275,21 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyPreviewText(for: msg), "Call") } + func testReplyBar_CallWithCaptionUsesCaption() { + let msg = makeMessage(text: "Missed you", attachments: [makeAttachment(type: .call)]) + XCTAssertEqual(replyPreviewText(for: msg), "Missed you") + } + + func testReplyBar_VoiceMessage() { + let msg = makeMessage(attachments: [makeAttachment(type: .voice)]) + XCTAssertEqual(replyPreviewText(for: msg), "Voice message") + } + + func testReplyBar_VoiceWithCaptionUsesCaption() { + let msg = makeMessage(text: "Listen", attachments: [makeAttachment(type: .voice)]) + XCTAssertEqual(replyPreviewText(for: msg), "Listen") + } + // MARK: Text func testReplyBar_TextOnly() { @@ -216,6 +297,13 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyPreviewText(for: msg), "Hello world") } + func testReplyBar_TextShortcodesAreDecodedToEmoji() { + let msg = makeMessage(text: "ok :emoji_1f44d:") + let preview = replyPreviewText(for: msg) + XCTAssertFalse(preview.contains(":emoji_")) + XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) + } + func testReplyBar_Empty() { let msg = makeMessage() XCTAssertEqual(replyPreviewText(for: msg), "") @@ -281,6 +369,11 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyQuotePreviewText(for: reply), "Call") } + func testQuote_VoiceMessage() { + let reply = makeReply(attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)]) + XCTAssertEqual(replyQuotePreviewText(for: reply), "Voice message") + } + func testQuote_UnknownType() { let reply = makeReply(attachments: [makeReplyAttachment(type: 99)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Attachment") @@ -291,6 +384,13 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(replyQuotePreviewText(for: reply), "Some text") } + func testQuote_TextShortcodesAreDecodedToEmoji() { + let reply = makeReply(message: "ok :emoji_1f44d:", attachments: []) + let preview = replyQuotePreviewText(for: reply) + XCTAssertFalse(preview.contains(":emoji_")) + XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) + } + func testQuote_WhitespaceText() { let reply = makeReply(message: " ", attachments: [makeReplyAttachment(type: 0)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") @@ -320,6 +420,10 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .call)]), "Call") } + func testChatList_Voice() { + XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .voice)]), "Voice message") + } + func testChatList_TextWins() { XCTAssertEqual(chatListPreviewText(text: "Hello", attachments: [makeAttachment(type: .image)]), "Hello") } @@ -339,6 +443,41 @@ final class ReplyPreviewTextTests: XCTestCase { XCTAssertEqual(chatListPreviewText(text: control, attachments: [makeAttachment(type: .call)]), "Call") } + // ========================================================================= + // MARK: - In-App Banner Preview (SessionManager) + // ========================================================================= + + func testInAppBanner_TextShortcodesAreDecodedToEmoji() { + let input = "ok :emoji_1f44d:" + let preview = inAppBannerPreviewText(text: input, attachments: []) + XCTAssertFalse(preview.contains(":emoji_")) + XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) + } + + func testInAppBanner_VoiceAttachmentLabel() { + let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .voice)]) + XCTAssertEqual(preview, "Voice message") + } + + func testInAppBanner_AvatarAttachmentLabel() { + let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .avatar)]) + XCTAssertEqual(preview, "Avatar") + } + + // ========================================================================= + // MARK: - Native Reply Cache (NativeMessageList UIKit path) + // ========================================================================= + + func testNativeReplyCache_VoiceAttachmentLabel() { + let reply = makeReply(message: "", attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)]) + XCTAssertEqual(nativeReplyCacheText(outerMessageText: "fix", reply: reply), "Voice message") + } + + func testNativeReplyCache_DecodesEmojiShortcodes() { + let reply = makeReply(message: "ok :emoji_1f44d:", attachments: []) + XCTAssertEqual(nativeReplyCacheText(outerMessageText: "text", reply: reply), "ok 👍") + } + // ========================================================================= // MARK: - AttachmentType JSON Decode Fault Tolerance // =========================================================================