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
// =========================================================================