Голосовые сообщения - анимация кнопки микрофона + панель записи с таймером

This commit is contained in:
2026-04-11 01:46:09 +05:00
parent 49fc49ffda
commit 667ba06967
20 changed files with 3157 additions and 109 deletions

View File

@@ -35,16 +35,21 @@ struct ChatListView: View {
@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
// Custom search bar collapses on scroll (Telegram: 54pt distance)
customSearchBar
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
.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
@@ -78,6 +83,9 @@ struct ChatListView: View {
.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)
}
@@ -166,7 +174,9 @@ struct ChatListView: View {
// MARK: - Cancel Search
private func cancelSearch() {
isSearchActive = false
withAnimation(.easeInOut(duration: 0.3)) {
isSearchActive = false
}
isSearchFocused = false
searchText = ""
viewModel.setSearchQuery("")
@@ -229,12 +239,12 @@ private extension ChatListView {
.padding(.horizontal, 12)
}
}
.frame(height: 42)
.frame(height: 44)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
if !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -244,20 +254,20 @@ private extension ChatListView {
}
.background {
if isSearchActive {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(RosettaColors.Adaptive.searchBarBorder, lineWidth: 0.5)
}
} else {
RoundedRectangle(cornerRadius: 24, style: .continuous)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(RosettaColors.Adaptive.searchBarFill)
}
}
.onChange(of: isSearchFocused) { _, focused in
if focused && !isSearchActive {
withAnimation(.easeInOut(duration: 0.25)) {
withAnimation(.easeInOut(duration: 0.14)) {
isSearchActive = true
}
}
@@ -310,6 +320,9 @@ private extension ChatListView {
hasPinnedChats = pinned
}
}
},
onScrollOffsetChange: { expansion in
searchBarExpansion = expansion
}
)
}
@@ -593,6 +606,7 @@ private struct DeviceVerificationContentRouter: View {
@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
@@ -611,7 +625,8 @@ private struct DeviceVerificationContentRouter: View {
viewModel: viewModel,
navigationState: navigationState,
onShowRequests: onShowRequests,
onPinnedStateChange: onPinnedStateChange
onPinnedStateChange: onPinnedStateChange,
onScrollOffsetChange: onScrollOffsetChange
)
}
}
@@ -626,6 +641,7 @@ private struct ChatListDialogContent: View {
@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<String>] = [:]
@@ -659,85 +675,53 @@ private struct ChatListDialogContent: View {
}
}
// MARK: - Dialog List
private static let topAnchorId = "chatlist_top"
// MARK: - Dialog List (UIKit UICollectionView)
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
ScrollViewReader { scrollProxy in
List {
Group {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
ChatRowShimmerView()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} else {
// Telegram-style "Request Chats" row at top (like Archived Chats)
if requestsCount > 0 {
RequestChatsRow(count: requestsCount, onTap: onShowRequests)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
}
if !pinned.isEmpty {
ForEach(pinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == pinned.first?.id && requestsCount == 0)
.environment(\.rowBackgroundColor, RosettaColors.Adaptive.pinnedSectionBackground)
.listRowBackground(RosettaColors.Adaptive.pinnedSectionBackground)
// 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)
}
}
ForEach(unpinned, id: \.id) { dialog in
chatRow(dialog, isFirst: dialog.id == unpinned.first?.id && pinned.isEmpty && requestsCount == 0)
}
}
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
.modifier(ClassicSwipeActionsModifier())
// Scroll-to-top: tap "Chats" in toolbar
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
// Scroll to first dialog ID (pinned or unpinned)
let firstId = pinned.first?.id ?? unpinned.first?.id
if let firstId {
withAnimation(.easeOut(duration: 0.3)) {
scrollProxy.scrollTo(firstId, anchor: .top)
}
.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)
}
)
}
}
} // ScrollViewReader
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
/// of SessionManager.syncBatchInProgress from this view's observation scope.
/// viewModel + navigationState passed as plain `let` (not @ObservedObject)
/// stable class references don't trigger row re-evaluation on parent re-render.
SyncAwareChatRow(
dialog: dialog,
isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true),
typingSenderNames: {
guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] }
return senderKeys.map { sk in
DialogRepository.shared.dialogs[sk]?.opponentTitle
?? String(sk.prefix(8))
}
}(),
isFirst: isFirst,
viewModel: viewModel,
navigationState: navigationState
)
}
}

View File

@@ -0,0 +1,886 @@
import UIKit
import SwiftUI
// MARK: - ChatListCell
/// UICollectionViewCell with manual frame layout matching Telegram iOS ChatListItemNode.
/// All measurements taken from Telegram source: `ChatListItem.swift` asyncLayout().
///
/// No Auto Layout all frames computed in `layoutSubviews()` for maximum scroll performance.
final class ChatListCell: UICollectionViewCell {
// MARK: - Layout Constants (Telegram-exact)
enum CellLayout {
static let avatarDiameter: CGFloat = 60
static let avatarLeftPadding: CGFloat = 10
static let avatarToTextGap: CGFloat = 8 // visual gap after avatar
static let contentLeftInset: CGFloat = 80 // avatarLeft(10) + avatar(60) + gap(10)
static let contentRightInset: CGFloat = 10
static let contentTopOffset: CGFloat = 8
static let titleSpacing: CGFloat = -1 // negative, Telegram's titleSpacing
static let dateYOffset: CGFloat = 2 // relative to contentTop
static let badgeDiameter: CGFloat = 20
static let badgeSpacing: CGFloat = 6
static let badgeBottomInset: CGFloat = 2 // from content bottom
static let separatorInset: CGFloat = 80
static let onlineDotSize: CGFloat = 14 // 24% of 60
static let onlineBorderWidth: CGFloat = 2.5
static let statusIconSize: CGFloat = 16
static let itemHeight: CGFloat = 76
}
// MARK: - Subviews
// Avatar
let avatarBackgroundView = UIView()
let avatarInitialsLabel = UILabel()
let avatarImageView = UIImageView()
let onlineIndicator = UIView()
private let onlineDotInner = UIView()
// Group avatar fallback
let groupIconView = UIImageView()
// Title row
let titleLabel = UILabel()
let verifiedBadge = UIImageView()
let mutedIconView = UIImageView()
// Author (group sender name separate line between title and message)
let authorLabel = UILabel()
// Message row
let messageLabel = UILabel()
// Trailing column
let dateLabel = UILabel()
let statusImageView = UIImageView()
let badgeContainer = UIView()
let badgeLabel = UILabel()
let mentionBadgeContainer = UIView()
let mentionLabel = UILabel()
let pinnedIconView = UIImageView()
// Separator
let separatorView = UIView()
// MARK: - State
private var isPinned = false
private var wasBadgeVisible = false
private var wasMentionBadgeVisible = false
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupSubviews() {
backgroundColor = .clear
contentView.backgroundColor = .clear
// Avatar background (colored circle for initials)
avatarBackgroundView.clipsToBounds = true
avatarBackgroundView.layer.cornerRadius = CellLayout.avatarDiameter / 2
contentView.addSubview(avatarBackgroundView)
// Initials label
avatarInitialsLabel.textAlignment = .center
avatarInitialsLabel.font = .systemFont(ofSize: CellLayout.avatarDiameter * 0.38, weight: .bold)
contentView.addSubview(avatarInitialsLabel)
// Group icon
groupIconView.contentMode = .center
groupIconView.tintColor = .white.withAlphaComponent(0.9)
groupIconView.image = UIImage(systemName: "person.2.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
groupIconView.isHidden = true
contentView.addSubview(groupIconView)
// Avatar image (photo, on top of background)
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
avatarImageView.layer.cornerRadius = CellLayout.avatarDiameter / 2
contentView.addSubview(avatarImageView)
// Online indicator
onlineIndicator.isHidden = true
onlineIndicator.layer.cornerRadius = CellLayout.onlineDotSize / 2
contentView.addSubview(onlineIndicator)
onlineDotInner.layer.cornerRadius = (CellLayout.onlineDotSize - CellLayout.onlineBorderWidth * 2) / 2
onlineDotInner.backgroundColor = UIColor(RosettaColors.primaryBlue)
onlineIndicator.addSubview(onlineDotInner)
// Title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(titleLabel)
// Verified badge
verifiedBadge.contentMode = .scaleAspectFit
verifiedBadge.isHidden = true
contentView.addSubview(verifiedBadge)
// Muted icon
mutedIconView.contentMode = .scaleAspectFit
mutedIconView.image = UIImage(systemName: "speaker.slash.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 12, weight: .regular)
)
mutedIconView.isHidden = true
contentView.addSubview(mutedIconView)
// Author (group sender name on own line)
authorLabel.font = .systemFont(ofSize: 15, weight: .regular)
authorLabel.lineBreakMode = .byTruncatingTail
authorLabel.isHidden = true
contentView.addSubview(authorLabel)
// Message
messageLabel.font = .systemFont(ofSize: 15, weight: .regular)
messageLabel.numberOfLines = 2
messageLabel.lineBreakMode = .byTruncatingTail
contentView.addSubview(messageLabel)
// Date
dateLabel.font = .systemFont(ofSize: 14, weight: .regular)
dateLabel.textAlignment = .right
contentView.addSubview(dateLabel)
// Status icon (checkmarks)
statusImageView.contentMode = .scaleAspectFit
statusImageView.isHidden = true
contentView.addSubview(statusImageView)
// Badge container (capsule)
badgeContainer.isHidden = true
badgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
contentView.addSubview(badgeContainer)
// Badge label
badgeLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
badgeLabel.textColor = .white
badgeLabel.textAlignment = .center
badgeContainer.addSubview(badgeLabel)
// Mention badge
mentionBadgeContainer.isHidden = true
mentionBadgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
contentView.addSubview(mentionBadgeContainer)
mentionLabel.font = .systemFont(ofSize: 14, weight: .medium)
mentionLabel.textColor = .white
mentionLabel.text = "@"
mentionLabel.textAlignment = .center
mentionBadgeContainer.addSubview(mentionLabel)
// Pin icon
pinnedIconView.contentMode = .scaleAspectFit
pinnedIconView.image = UIImage(systemName: "pin.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)
)
pinnedIconView.isHidden = true
pinnedIconView.transform = CGAffineTransform(rotationAngle: .pi / 4)
contentView.addSubview(pinnedIconView)
// Separator
separatorView.isUserInteractionEnabled = false
contentView.addSubview(separatorView)
}
// MARK: - Layout (manual frame calculation)
override func layoutSubviews() {
super.layoutSubviews()
let w = contentView.bounds.width
let h = contentView.bounds.height
let scale = UIScreen.main.scale
// Avatar
let avatarY = floor((h - CellLayout.avatarDiameter) / 2)
let avatarFrame = CGRect(
x: CellLayout.avatarLeftPadding,
y: avatarY,
width: CellLayout.avatarDiameter,
height: CellLayout.avatarDiameter
)
avatarBackgroundView.frame = avatarFrame
avatarInitialsLabel.frame = avatarFrame
avatarImageView.frame = avatarFrame
groupIconView.frame = avatarFrame
// Online indicator (bottom-right of avatar)
let onlineX = avatarFrame.maxX - CellLayout.onlineDotSize + 1
let onlineY = avatarFrame.maxY - CellLayout.onlineDotSize + 1
onlineIndicator.frame = CGRect(
x: onlineX, y: onlineY,
width: CellLayout.onlineDotSize, height: CellLayout.onlineDotSize
)
onlineDotInner.frame = onlineIndicator.bounds.insetBy(
dx: CellLayout.onlineBorderWidth, dy: CellLayout.onlineBorderWidth
)
// Content area
let contentLeft = CellLayout.contentLeftInset
let contentRight = w - CellLayout.contentRightInset
let contentTop = CellLayout.contentTopOffset
// Top row: [title + icons ...] [status] [date]
// Date measure first (determines title max width)
let dateSize = dateLabel.sizeThatFits(CGSize(width: 120, height: 20))
let dateX = contentRight - dateSize.width
let dateY = contentTop + CellLayout.dateYOffset
dateLabel.frame = CGRect(x: dateX, y: dateY, width: ceil(dateSize.width), height: ceil(dateSize.height))
// Status icon left of date
var titleRightBound = dateX - 6 // gap between title area and date
if !statusImageView.isHidden {
let statusW: CGFloat = CellLayout.statusIconSize
let statusH: CGFloat = CellLayout.statusIconSize
let statusX = dateX - statusW - 2
let statusY = dateY + floor((dateSize.height - statusH) / 2)
statusImageView.frame = CGRect(x: statusX, y: statusY, width: statusW, height: statusH)
titleRightBound = statusX - 4
}
// Title measure within available width
let titleAvailableWidth = titleRightBound - contentLeft
// Account for verified (16+3) and muted (14+3) if visible
var titleIconsWidth: CGFloat = 0
if !verifiedBadge.isHidden { titleIconsWidth += 16 + 3 }
if !mutedIconView.isHidden { titleIconsWidth += 14 + 3 }
let titleMaxWidth = max(0, titleAvailableWidth - titleIconsWidth)
let titleSize = titleLabel.sizeThatFits(CGSize(width: titleMaxWidth, height: 22))
let screenPixelTitle = 1.0 / scale
titleLabel.frame = CGRect(
x: contentLeft, y: contentTop + screenPixelTitle,
width: min(ceil(titleSize.width), titleMaxWidth), height: ceil(titleSize.height)
)
// Verified badge right of title text
var iconX = titleLabel.frame.maxX + 3
if !verifiedBadge.isHidden {
let s: CGFloat = 16
verifiedBadge.frame = CGRect(
x: iconX,
y: contentTop + floor((titleSize.height - s) / 2),
width: s, height: s
)
iconX = verifiedBadge.frame.maxX + 3
}
// Muted icon
if !mutedIconView.isHidden {
let s: CGFloat = 14
mutedIconView.frame = CGRect(
x: iconX,
y: contentTop + floor((titleSize.height - s) / 2),
width: s, height: s
)
}
// Bottom row: [message ...] [badge/pin]
// Badges positioned at bottom of content area
var badgeRightEdge = contentRight
// Telegram: badges aligned with content bottom - 2pt inset
let badgeY = h - CellLayout.badgeDiameter - 10
if !badgeContainer.isHidden {
let textSize = badgeLabel.sizeThatFits(CGSize(width: 100, height: CellLayout.badgeDiameter))
let badgeW = max(CellLayout.badgeDiameter, ceil(textSize.width) + 10)
badgeContainer.frame = CGRect(
x: badgeRightEdge - badgeW, y: badgeY,
width: badgeW, height: CellLayout.badgeDiameter
)
badgeContainer.layer.cornerRadius = CellLayout.badgeDiameter / 2
badgeLabel.frame = badgeContainer.bounds
badgeRightEdge = badgeContainer.frame.minX - CellLayout.badgeSpacing
}
if !mentionBadgeContainer.isHidden {
mentionBadgeContainer.frame = CGRect(
x: badgeRightEdge - CellLayout.badgeDiameter, y: badgeY,
width: CellLayout.badgeDiameter, height: CellLayout.badgeDiameter
)
mentionLabel.frame = mentionBadgeContainer.bounds
badgeRightEdge = mentionBadgeContainer.frame.minX - CellLayout.badgeSpacing
}
if !pinnedIconView.isHidden {
let pinS: CGFloat = 16
pinnedIconView.frame = CGRect(
x: badgeRightEdge - pinS,
y: badgeY + floor((CellLayout.badgeDiameter - pinS) / 2),
width: pinS, height: pinS
)
badgeRightEdge = pinnedIconView.frame.minX - CellLayout.badgeSpacing
}
// Author + Message fixed Y positions from Telegram screenshot
// Measured from Telegram iOS at 76pt cell height, 16pt title, 15pt text:
// 1:1: title ~8pt, message ~27pt
// Group: title ~8pt, author ~27pt, message ~45pt
let textLeft = contentLeft - 1
let messageMaxW = badgeRightEdge - contentLeft
if !authorLabel.isHidden {
let authorSize = authorLabel.sizeThatFits(CGSize(width: messageMaxW, height: 22))
authorLabel.frame = CGRect(
x: textLeft, y: 30,
width: min(ceil(authorSize.width), messageMaxW),
height: 20
)
messageLabel.frame = CGRect(
x: textLeft, y: 50,
width: max(0, messageMaxW), height: 20
)
} else {
authorLabel.frame = .zero
messageLabel.frame = CGRect(
x: textLeft, y: 21,
width: max(0, messageMaxW), height: 38
)
}
// Separator
let separatorHeight = 1.0 / scale
separatorView.frame = CGRect(
x: CellLayout.separatorInset,
y: h - separatorHeight,
width: w - CellLayout.separatorInset,
height: separatorHeight
)
}
// MARK: - Configuration
/// Message text cache (shared across cells, avoids regex per configure).
private static var messageTextCache: [String: String] = [:]
func configure(with dialog: Dialog, isSyncing: Bool) {
let isDark = traitCollection.userInterfaceStyle == .dark
isPinned = dialog.isPinned
// Colors
let titleColor = isDark ? UIColor.white : UIColor.black
let secondaryColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
let accentBlue = UIColor(RosettaColors.figmaBlue)
let mutedBadgeBg = isDark
? UIColor(red: 0x66/255, green: 0x66/255, blue: 0x66/255, alpha: 1)
: UIColor(red: 0xB6/255, green: 0xB6/255, blue: 0xBB/255, alpha: 1)
let separatorColor = 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)
let pinnedBg = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1)
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
// Background pinned section uses decoration view, cells always clear
contentView.backgroundColor = .clear
// Online indicator background matches section bg
let cellBg = dialog.isPinned ? pinnedBg : (isDark ? UIColor.black : UIColor.white)
onlineIndicator.backgroundColor = cellBg
// Separator
separatorView.backgroundColor = separatorColor
// Avatar
configureAvatar(dialog: dialog, isDark: isDark)
// Online
onlineIndicator.isHidden = !dialog.isOnline || dialog.isSavedMessages
// Title
titleLabel.text = displayTitle(for: dialog)
titleLabel.textColor = titleColor
// Verified
configureVerified(dialog: dialog)
// Muted
mutedIconView.isHidden = !dialog.isMuted
mutedIconView.tintColor = secondaryColor
// Message text (typing is NOT shown in chat list only inside chat detail)
configureMessageText(dialog: dialog, secondaryColor: secondaryColor, titleColor: titleColor)
// Date
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
dateLabel.textColor = (dialog.unreadCount > 0 && !dialog.isMuted) ? accentBlue : secondaryColor
// Delivery status
configureDeliveryStatus(dialog: dialog, secondaryColor: secondaryColor, accentBlue: accentBlue)
// Badge
configureBadge(dialog: dialog, isSyncing: isSyncing, accentBlue: accentBlue, mutedBadgeBg: mutedBadgeBg)
// Pin
pinnedIconView.isHidden = !(dialog.isPinned && dialog.unreadCount == 0)
pinnedIconView.tintColor = secondaryColor
setNeedsLayout()
}
// MARK: - Avatar Configuration
private func configureAvatar(dialog: Dialog, isDark: Bool) {
let colorPair = RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count]
let image = dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
// Reset visibility
avatarBackgroundView.isHidden = false
avatarImageView.isHidden = true
avatarInitialsLabel.isHidden = true
groupIconView.isHidden = true
if dialog.isSavedMessages {
avatarBackgroundView.backgroundColor = UIColor(RosettaColors.primaryBlue)
groupIconView.isHidden = false
groupIconView.image = UIImage(systemName: "bookmark.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: CellLayout.avatarDiameter * 0.38, weight: .semibold)
)
groupIconView.tintColor = .white
} else if let image {
avatarImageView.image = image
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)
} else {
// Initials Mantine "light" variant (matches AvatarView.swift)
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
}
}
// MARK: - Verified Badge
/// Cached verified badge images (rendered once from SVG paths).
private static var verifiedImageCache: [Int: UIImage] = [:]
private static func verifiedImage(level: Int, size: CGFloat) -> UIImage {
if let cached = verifiedImageCache[level] { return cached }
let pathData: String
switch level {
case 2: pathData = TablerIconPath.shieldCheckFilled
case 3...: pathData = TablerIconPath.arrowBadgeDownFilled
default: pathData = TablerIconPath.rosetteDiscountCheckFilled
}
let image = renderSVGPath(pathData, viewBox: CGSize(width: 24, height: 24), size: CGSize(width: size, height: size))
verifiedImageCache[level] = image
return image
}
private func configureVerified(dialog: Dialog) {
let level = dialog.effectiveVerified
guard level > 0 && !dialog.isSavedMessages else {
verifiedBadge.isHidden = true
return
}
verifiedBadge.isHidden = false
verifiedBadge.image = Self.verifiedImage(level: level, size: 16)
.withRenderingMode(.alwaysTemplate)
switch level {
case 1:
verifiedBadge.tintColor = UIColor(RosettaColors.primaryBlue)
case 2:
verifiedBadge.tintColor = UIColor(RosettaColors.success)
default:
verifiedBadge.tintColor = UIColor(red: 1, green: 215/255, blue: 0, alpha: 1)
}
}
// MARK: - Delivery Status
/// Cached checkmark images (rendered once from SwiftUI Shapes).
private static let singleCheckImage: UIImage = renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3))
private static let doubleCheckImage: UIImage = renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3))
/// Cached error indicator (Telegram: red circle with white exclamation).
private static let errorImage: UIImage = {
let size = CGSize(width: 16, height: 16)
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
// Red circle
let circlePath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size))
UIColor(red: 0xFF/255, green: 0x3B/255, blue: 0x30/255, alpha: 1).setFill()
circlePath.fill()
// White exclamation mark
let lineWidth: CGFloat = 1.8
let topY: CGFloat = 3.5
let bottomY: CGFloat = 10
let dotY: CGFloat = 12.5
let centerX = size.width / 2
// Stem
let stem = UIBezierPath()
stem.move(to: CGPoint(x: centerX, y: topY))
stem.addLine(to: CGPoint(x: centerX, y: bottomY))
stem.lineWidth = lineWidth
stem.lineCapStyle = .round
UIColor.white.setStroke()
stem.stroke()
// Dot
let dotSize: CGFloat = 2.0
let dotRect = CGRect(x: centerX - dotSize/2, y: dotY, width: dotSize, height: dotSize)
let dot = UIBezierPath(ovalIn: dotRect)
UIColor.white.setFill()
dot.fill()
}
}()
private func configureDeliveryStatus(dialog: Dialog, secondaryColor: UIColor, accentBlue: UIColor) {
guard dialog.lastMessageFromMe && !dialog.isSavedMessages else {
statusImageView.isHidden = true
return
}
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
// Read blue double checkmarks
statusImageView.isHidden = false
statusImageView.image = Self.doubleCheckImage.withRenderingMode(.alwaysTemplate)
statusImageView.tintColor = accentBlue
} else if dialog.lastMessageDelivered == .error {
// Error red indicator
statusImageView.isHidden = false
statusImageView.image = Self.errorImage
statusImageView.tintColor = nil
} else {
// Waiting / delivered but not read hide (Telegram doesn't show in chat list)
statusImageView.isHidden = true
}
}
// MARK: - Badge
private func configureBadge(dialog: Dialog, isSyncing: Bool, accentBlue: UIColor, mutedBadgeBg: UIColor) {
let count = dialog.unreadCount
let showBadge = count > 0 && !isSyncing
if showBadge {
let text: String
if count > 999 { text = "\(count / 1000)K" }
else if count > 99 { text = "99+" }
else { text = "\(count)" }
badgeLabel.text = text
badgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue
}
// Animate badge appear/disappear (Telegram: scale spring)
animateBadgeTransition(view: badgeContainer, shouldShow: showBadge, wasVisible: &wasBadgeVisible)
// Mention badge
let showMention = dialog.hasMention && count > 0 && !isSyncing
if showMention {
mentionBadgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue
}
animateBadgeTransition(view: mentionBadgeContainer, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
}
/// Telegram badge animation: appear = scale 0.00011.2 (0.2s) 1.0 (0.12s settle);
/// disappear = scale 1.00.0001 (0.12s). Uses transform (not frame) + .allowUserInteraction.
private func animateBadgeTransition(view: UIView, shouldShow: Bool, wasVisible: inout Bool) {
let wasShowing = wasVisible
wasVisible = shouldShow
if shouldShow && !wasShowing {
// Appear: pop in with bounce
view.isHidden = false
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) {
view.transform = CGAffineTransform(scaleX: 1.15, y: 1.15)
} completion: { _ in
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
view.transform = .identity
}
}
} else if !shouldShow && wasShowing {
// Disappear: scale down
UIView.animate(withDuration: 0.12, delay: 0, options: [.curveEaseIn, .allowUserInteraction]) {
view.transform = CGAffineTransform(scaleX: 0.0001, y: 0.0001)
} completion: { finished in
if finished {
view.isHidden = true
view.transform = .identity
}
}
} else {
// No transition just set visibility
view.isHidden = !shouldShow
view.transform = .identity
}
}
// MARK: - Message Text
private func configureMessageText(dialog: Dialog, secondaryColor: UIColor, titleColor: UIColor) {
let text = resolveMessageText(dialog: dialog)
// Group chats: sender name on SEPARATE LINE (Telegram layout)
// authorNameColor = white (dark) / black (light)
let showAuthor = dialog.isGroup && !dialog.isSavedMessages
&& !dialog.lastMessageSenderKey.isEmpty && !text.isEmpty
if showAuthor {
let senderName: String
if dialog.lastMessageFromMe {
senderName = "You"
} else {
let lookup = DialogRepository.shared.dialogs[dialog.lastMessageSenderKey]
senderName = lookup?.opponentTitle.isEmpty == false
? lookup!.opponentTitle
: String(dialog.lastMessageSenderKey.prefix(8))
}
authorLabel.isHidden = false
authorLabel.text = senderName
authorLabel.textColor = titleColor
messageLabel.numberOfLines = 1 // 1 line when author shown
} else {
authorLabel.isHidden = true
messageLabel.numberOfLines = 2
}
messageLabel.attributedText = nil
messageLabel.text = text
messageLabel.textColor = secondaryColor
}
private func resolveMessageText(dialog: Dialog) -> String {
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return "No messages yet" }
if raw.hasPrefix("#group:") { return "Group invite" }
if Self.looksLikeCiphertext(raw) { return "No messages yet" }
if let cached = Self.messageTextCache[dialog.lastMessage] { return cached }
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
let result = EmojiParser.replaceShortcodes(in: cleaned)
if Self.messageTextCache.count > 500 {
let keys = Array(Self.messageTextCache.keys.prefix(250))
for key in keys { Self.messageTextCache.removeValue(forKey: key) }
}
Self.messageTextCache[dialog.lastMessage] = result
return result
}
private static func looksLikeCiphertext(_ text: String) -> Bool {
if text.hasPrefix("CHNK:") { return true }
let parts = text.components(separatedBy: ":")
if parts.count == 2 {
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
let bothBase64 = parts.allSatisfy { part in
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
}
if bothBase64 { return true }
}
if text.count >= 40 {
let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
if text.unicodeScalars.allSatisfy({ hex.contains($0) }) { return true }
}
return false
}
// MARK: - Display Title
private func displayTitle(for dialog: Dialog) -> String {
if dialog.isSavedMessages { return "Saved Messages" }
if dialog.isGroup {
let meta = GroupRepository.shared.groupMetadata(
account: dialog.account,
groupDialogKey: dialog.opponentKey
)
if let title = meta?.title, !title.isEmpty { return title }
}
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
return String(dialog.opponentKey.prefix(12))
}
// MARK: - Time Formatting
private static let timeFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE"; return f
}()
private static let dateFormatter: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "dd.MM.yy"; return f
}()
private static var timeStringCache: [Int64: String] = [:]
private func formatTime(_ timestamp: Int64) -> String {
guard timestamp > 0 else { return "" }
if let cached = Self.timeStringCache[timestamp] { return cached }
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000)
let now = Date()
let cal = Calendar.current
let result: String
if cal.isDateInToday(date) {
result = Self.timeFormatter.string(from: date)
} else if cal.isDateInYesterday(date) {
result = "Yesterday"
} else if let days = cal.dateComponents([.day], from: date, to: now).day, days < 7 {
result = Self.dayFormatter.string(from: date)
} else {
result = Self.dateFormatter.string(from: date)
}
if Self.timeStringCache.count > 500 {
let keys = Array(Self.timeStringCache.keys.prefix(250))
for key in keys { Self.timeStringCache.removeValue(forKey: key) }
}
Self.timeStringCache[timestamp] = result
return result
}
// MARK: - Reuse
override func prepareForReuse() {
super.prepareForReuse()
avatarImageView.image = nil
avatarImageView.isHidden = true
avatarBackgroundView.isHidden = false
avatarInitialsLabel.isHidden = false
groupIconView.isHidden = true
verifiedBadge.isHidden = true
mutedIconView.isHidden = true
statusImageView.isHidden = true
badgeContainer.isHidden = true
mentionBadgeContainer.isHidden = true
pinnedIconView.isHidden = true
onlineIndicator.isHidden = true
contentView.backgroundColor = .clear
messageLabel.attributedText = nil
messageLabel.numberOfLines = 2
authorLabel.isHidden = true
// Badge animation state
wasBadgeVisible = false
wasMentionBadgeVisible = false
badgeContainer.transform = .identity
mentionBadgeContainer.transform = .identity
}
// MARK: - Highlight
override var isHighlighted: Bool {
didSet {
let isDark = traitCollection.userInterfaceStyle == .dark
let pinnedBg = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1)
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
// Telegram-exact: #E5E5EA light, #121212 dark (normal); #E5E5EA/#2B2B2C (pinned)
let highlightBg: UIColor
if isPinned {
highlightBg = isDark
? UIColor(red: 0x2B/255, green: 0x2B/255, blue: 0x2C/255, alpha: 1)
: UIColor(red: 0xE5/255, green: 0xE5/255, blue: 0xEA/255, alpha: 1)
} else {
highlightBg = isDark
? UIColor(red: 0x12/255, green: 0x12/255, blue: 0x12/255, alpha: 1)
: UIColor(red: 0xE5/255, green: 0xE5/255, blue: 0xEA/255, alpha: 1)
}
if isHighlighted {
contentView.backgroundColor = highlightBg
} else {
// Telegram: 0.3s delay + 0.7s ease-out fade
// Pinned section background is handled by decoration view, so always fade to clear
UIView.animate(withDuration: 0.7, delay: 0.3, options: .curveEaseOut) {
self.contentView.backgroundColor = .clear
}
}
}
}
// MARK: - Separator Visibility
func setSeparatorHidden(_ hidden: Bool) {
separatorView.isHidden = hidden
}
}
// MARK: - UIFont Rounded Helper
private extension UIFont {
func rounded() -> UIFont {
guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self }
return UIFont(descriptor: descriptor, size: 0)
}
}
// MARK: - UIColor Blending Helper
private extension UIColor {
func blended(with color: UIColor, alpha: CGFloat) -> UIColor {
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
return UIColor(
red: r1 * (1 - alpha) + r2 * alpha,
green: g1 * (1 - alpha) + g2 * alpha,
blue: b1 * (1 - alpha) + b2 * alpha,
alpha: 1
)
}
}
// MARK: - Shape UIImage Rendering
/// Renders a SwiftUI Shape into a UIImage (used for checkmarks).
private func renderShape<S: Shape>(_ shape: S, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let path = shape.path(in: CGRect(origin: .zero, size: size))
let cgPath = path.cgPath
ctx.cgContext.addPath(cgPath)
ctx.cgContext.setFillColor(UIColor.black.cgColor)
ctx.cgContext.fillPath()
}
}
/// Renders an SVG path string into a UIImage (used for verified badges).
private func renderSVGPath(_ pathData: String, viewBox: CGSize, size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { ctx in
let scale = CGAffineTransform(
scaleX: size.width / viewBox.width,
y: size.height / viewBox.height
)
let svgPath = SVGPathShape(pathData: pathData, viewBox: viewBox)
let swiftUIPath = svgPath.path(in: CGRect(origin: .zero, size: size))
ctx.cgContext.addPath(swiftUIPath.cgPath)
ctx.cgContext.setFillColor(UIColor.black.cgColor)
ctx.cgContext.fillPath()
}
}

View File

@@ -0,0 +1,530 @@
import UIKit
import SwiftUI
// MARK: - ChatListCollectionController
/// UIViewController hosting a UICollectionView for the chat list.
/// Uses DiffableDataSource for smooth animated updates and manual-frame ChatListCell
/// for Telegram-level scroll performance.
///
/// Integrates into SwiftUI via `ChatListCollectionView` (UIViewControllerRepresentable).
final class ChatListCollectionController: UIViewController {
// MARK: - Sections
enum Section: Int, CaseIterable {
case requests
case pinned
case unpinned
}
// MARK: - Callbacks (to SwiftUI)
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
var onTogglePin: ((Dialog) -> Void)?
var onToggleMute: ((Dialog) -> Void)?
var onPinnedStateChange: ((Bool) -> Void)?
var onShowRequests: (() -> Void)?
var onScrollToTopRequested: (() -> Void)?
var onScrollOffsetChange: ((CGFloat) -> Void)?
var onMarkAsRead: ((Dialog) -> Void)?
// MARK: - Data
private(set) var pinnedDialogs: [Dialog] = []
private(set) var unpinnedDialogs: [Dialog] = []
private(set) var requestsCount: Int = 0
private(set) var typingDialogs: [String: Set<String>] = [:]
private(set) var isSyncing: Bool = false
private var lastReportedExpansion: CGFloat = 1.0
// MARK: - UI
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, String>!
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
private var requestsCellRegistration: UICollectionView.CellRegistration<ChatListRequestsCell, Int>!
// Dialog lookup by ID for cell configuration
private var dialogMap: [String: Dialog] = [:]
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
setupCollectionView()
setupCellRegistrations()
setupDataSource()
setupScrollToTop()
}
// MARK: - Collection View Setup
private func setupCollectionView() {
let layout = createLayout()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.delegate = self
collectionView.prefetchDataSource = self
collectionView.keyboardDismissMode = .onDrag
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.contentInsetAdjustmentBehavior = .automatic
// Bottom inset so last cells aren't hidden behind tab bar
collectionView.contentInset.bottom = 80
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func createLayout() -> UICollectionViewCompositionalLayout {
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
listConfig.showsSeparators = false
listConfig.backgroundColor = .clear
// Swipe actions
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.trailingSwipeActions(for: indexPath)
}
listConfig.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.leadingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
section.interGroupSpacing = 0
// Add pinned section background decoration
if let self,
sectionIndex < self.dataSource?.snapshot().sectionIdentifiers.count ?? 0,
self.dataSource?.snapshot().sectionIdentifiers[sectionIndex] == .pinned {
let bgItem = NSCollectionLayoutDecorationItem.background(
elementKind: PinnedSectionBackgroundView.elementKind
)
section.decorationItems = [bgItem]
}
return section
}
layout.register(
PinnedSectionBackgroundView.self,
forDecorationViewOfKind: PinnedSectionBackgroundView.elementKind
)
return layout
}
// MARK: - Cell Registrations
private func setupCellRegistrations() {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
guard let self else { return }
cell.configure(with: dialog, isSyncing: self.isSyncing)
// Hide separator for first cell in first dialog section
let isFirstDialogSection = (self.sectionForIndexPath(indexPath) == .pinned && self.requestsCount == 0)
|| (self.sectionForIndexPath(indexPath) == .unpinned && self.pinnedDialogs.isEmpty && self.requestsCount == 0)
cell.setSeparatorHidden(indexPath.item == 0 && isFirstDialogSection)
}
requestsCellRegistration = UICollectionView.CellRegistration<ChatListRequestsCell, Int> {
cell, indexPath, count in
cell.configure(count: count)
}
}
// MARK: - Data Source
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, String>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, itemId in
guard let self else { return UICollectionViewCell() }
// CRITICAL: use sectionIdentifier, NOT rawValue mapping.
// When sections are skipped (e.g. no requests), indexPath.section=0
// could be .pinned, not .requests. rawValue mapping would be wrong.
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
if section == .requests {
return collectionView.dequeueConfiguredReusableCell(
using: self.requestsCellRegistration,
for: indexPath,
item: self.requestsCount
)
}
guard let dialog = self.dialogMap[itemId] else {
return UICollectionViewCell()
}
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration,
for: indexPath,
item: dialog
)
}
}
// MARK: - Update Data
func updateDialogs(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int,
typingDialogs: [String: Set<String>], isSyncing: Bool) {
self.typingDialogs = typingDialogs
self.isSyncing = isSyncing
// Check if structure changed (IDs or order)
let oldPinnedIds = self.pinnedDialogs.map(\.id)
let oldUnpinnedIds = self.unpinnedDialogs.map(\.id)
let newPinnedIds = pinned.map(\.id)
let newUnpinnedIds = unpinned.map(\.id)
let structureChanged = oldPinnedIds != newPinnedIds
|| oldUnpinnedIds != newUnpinnedIds
|| self.requestsCount != requestsCount
self.pinnedDialogs = pinned
self.unpinnedDialogs = unpinned
self.requestsCount = requestsCount
// Build lookup map
dialogMap.removeAll(keepingCapacity: true)
for d in pinned { dialogMap[d.id] = d }
for d in unpinned { dialogMap[d.id] = d }
if structureChanged {
// Structure changed rebuild snapshot (animate inserts/deletes/moves)
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
if requestsCount > 0 {
snapshot.appendSections([.requests])
snapshot.appendItems(["__requests__"], toSection: .requests)
}
if !pinned.isEmpty {
snapshot.appendSections([.pinned])
snapshot.appendItems(newPinnedIds, toSection: .pinned)
}
snapshot.appendSections([.unpinned])
snapshot.appendItems(newUnpinnedIds, toSection: .unpinned)
dataSource.apply(snapshot, animatingDifferences: true)
}
// 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)
}
}
/// Directly reconfigure only visible cells no snapshot rebuild, no animation.
/// This is the cheapest way to update cell content (online, read status, badges).
private func reconfigureVisibleCells() {
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell) else { continue }
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { continue }
if let chatCell = cell as? ChatListCell, let dialog = dialogMap[itemId] {
chatCell.configure(with: dialog, isSyncing: isSyncing)
} else if let reqCell = cell as? ChatListRequestsCell {
reqCell.configure(count: requestsCount)
}
}
}
// MARK: - Scroll to Top
private func setupScrollToTop() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleScrollToTop),
name: .chatListScrollToTop, object: nil
)
}
@objc private func handleScrollToTop() {
guard collectionView.numberOfSections > 0,
collectionView.numberOfItems(inSection: 0) > 0 else { return }
collectionView.scrollToItem(
at: IndexPath(item: 0, section: 0),
at: .top,
animated: true
)
// Reset search bar expansion
lastReportedExpansion = 1.0
onScrollOffsetChange?(1.0)
}
// MARK: - Swipe Actions
private func sectionForIndexPath(_ indexPath: IndexPath) -> Section? {
let identifiers = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < identifiers.count else { return nil }
return identifiers[indexPath.section]
}
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
// Delete
let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
completion(true)
}
delete.image = UIImage(systemName: "trash.fill")
delete.backgroundColor = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
// Mute/Unmute (skip for Saved Messages)
guard !dialog.isSavedMessages else {
return UISwipeActionsConfiguration(actions: [delete])
}
let mute = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
completion(true)
}
mute.image = UIImage(systemName: dialog.isMuted ? "bell.fill" : "bell.slash.fill")
mute.backgroundColor = dialog.isMuted
? UIColor.systemGreen
: UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
return UISwipeActionsConfiguration(actions: [delete, mute])
}
private func leadingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests else { return nil }
let dialog = dialogForIndexPath(indexPath)
guard let dialog else { return nil }
let pin = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
completion(true)
}
pin.image = UIImage(systemName: dialog.isPinned ? "pin.slash.fill" : "pin.fill")
pin.backgroundColor = UIColor(red: 1, green: 0.58, blue: 0, alpha: 1) // orange
let config = UISwipeActionsConfiguration(actions: [pin])
config.performsFirstActionWithFullSwipe = true
return config
}
private func dialogForIndexPath(_ indexPath: IndexPath) -> Dialog? {
guard let itemId = dataSource.itemIdentifier(for: indexPath) else { return nil }
return dialogMap[itemId]
}
}
// MARK: - UICollectionViewDelegate
extension ChatListCollectionController: UICollectionViewDelegate {
// MARK: - Scroll-Linked Search Bar (Telegram: 54pt collapse distance)
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)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let section = sectionForIndexPath(indexPath) else { return }
if section == .requests {
DispatchQueue.main.async { [weak self] in
self?.onShowRequests?()
}
return
}
guard let dialog = dialogForIndexPath(indexPath) else { return }
DispatchQueue.main.async { [weak self] in
self?.onSelectDialog?(dialog)
}
}
// MARK: - Context Menu (Long Press)
func collectionView(
_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint
) -> UIContextMenuConfiguration? {
guard let section = sectionForIndexPath(indexPath),
section != .requests,
let dialog = dialogForIndexPath(indexPath) else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
guard let self else { return nil }
let pinTitle = dialog.isPinned ? "Unpin" : "Pin"
let pinImage = UIImage(systemName: dialog.isPinned ? "pin.slash" : "pin")
let pinAction = UIAction(title: pinTitle, image: pinImage) { [weak self] _ in
DispatchQueue.main.async { self?.onTogglePin?(dialog) }
}
var actions: [UIAction] = [pinAction]
if !dialog.isSavedMessages {
let muteTitle = dialog.isMuted ? "Unmute" : "Mute"
let muteImage = UIImage(systemName: dialog.isMuted ? "bell" : "bell.slash")
let muteAction = UIAction(title: muteTitle, image: muteImage) { [weak self] _ in
DispatchQueue.main.async { self?.onToggleMute?(dialog) }
}
actions.append(muteAction)
}
if dialog.unreadCount > 0 {
let readAction = UIAction(
title: "Mark as Read",
image: UIImage(systemName: "checkmark.message")
) { [weak self] _ in
DispatchQueue.main.async { self?.onMarkAsRead?(dialog) }
}
actions.append(readAction)
}
let deleteAction = UIAction(
title: "Delete",
image: UIImage(systemName: "trash"),
attributes: .destructive
) { [weak self] _ in
DispatchQueue.main.async { self?.onDeleteDialog?(dialog) }
}
actions.append(deleteAction)
return UIMenu(children: actions)
}
}
}
// MARK: - UICollectionViewDataSourcePrefetching
extension ChatListCollectionController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let itemId = dataSource.itemIdentifier(for: indexPath),
let dialog = dialogMap[itemId],
!dialog.isSavedMessages else { continue }
// Warm avatar cache on background queue
let key = dialog.opponentKey
DispatchQueue.global(qos: .userInitiated).async {
_ = AvatarRepository.shared.loadAvatar(publicKey: key)
}
}
}
}
// MARK: - Request Chats Cell
/// Simple cell for "Request Chats" row at the top (like Telegram's Archived Chats).
final class ChatListRequestsCell: UICollectionViewCell {
private let avatarCircle = UIView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let separatorView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupSubviews() {
backgroundColor = .clear
contentView.backgroundColor = .clear
avatarCircle.backgroundColor = UIColor(RosettaColors.primaryBlue)
avatarCircle.layer.cornerRadius = 30
avatarCircle.clipsToBounds = true
contentView.addSubview(avatarCircle)
iconView.image = UIImage(systemName: "tray.and.arrow.down")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
)
iconView.tintColor = .white
iconView.contentMode = .center
contentView.addSubview(iconView)
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.text = "Request Chats"
contentView.addSubview(titleLabel)
subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
contentView.addSubview(subtitleLabel)
separatorView.isUserInteractionEnabled = false
contentView.addSubview(separatorView)
}
override func layoutSubviews() {
super.layoutSubviews()
let h = contentView.bounds.height
let w = contentView.bounds.width
let avatarY = floor((h - 60) / 2)
avatarCircle.frame = CGRect(x: 10, y: avatarY, width: 60, height: 60)
iconView.frame = avatarCircle.frame
titleLabel.frame = CGRect(x: 80, y: 14, width: w - 96, height: 22)
subtitleLabel.frame = CGRect(x: 80, y: 36, width: w - 96, height: 20)
let sepH = 1.0 / UIScreen.main.scale
separatorView.frame = CGRect(x: 80, y: h - sepH, width: w - 80, height: sepH)
}
func configure(count: Int) {
let isDark = traitCollection.userInterfaceStyle == .dark
titleLabel.textColor = isDark ? .white : .black
subtitleLabel.text = count == 1 ? "1 request" : "\(count) requests"
subtitleLabel.textColor = UIColor(red: 0x8E/255, green: 0x8E/255, blue: 0x93/255, alpha: 1)
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)
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = 76
return attrs
}
}
// MARK: - ChatListCell Self-Sizing Override
extension ChatListCell {
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = ChatListCell.CellLayout.itemHeight
return attrs
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
// MARK: - ChatListCollectionView
/// SwiftUI bridge wrapping `ChatListCollectionController` (UIKit UICollectionView).
///
/// Follows the same bridge pattern as `RosettaTabBarContainer` and `NativeMessageList`:
/// - Callbacks deferred via `DispatchQueue.main.async` to avoid SwiftUI layout-pass blocking.
/// - Data updates via `updateUIViewController` trigger DiffableDataSource snapshot apply.
struct ChatListCollectionView: UIViewControllerRepresentable {
// MARK: - Data
let pinnedDialogs: [Dialog]
let unpinnedDialogs: [Dialog]
let requestsCount: Int
let typingDialogs: [String: Set<String>]
let isSyncing: Bool
let isLoading: Bool
// MARK: - Callbacks
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
var onTogglePin: ((Dialog) -> Void)?
var onToggleMute: ((Dialog) -> Void)?
var onPinnedStateChange: ((Bool) -> Void)?
var onShowRequests: (() -> Void)?
var onScrollOffsetChange: ((CGFloat) -> Void)?
var onMarkAsRead: ((Dialog) -> Void)?
// MARK: - UIViewControllerRepresentable
func makeUIViewController(context: Context) -> ChatListCollectionController {
let controller = ChatListCollectionController()
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.onTogglePin = onTogglePin
controller.onToggleMute = onToggleMute
controller.onPinnedStateChange = onPinnedStateChange
controller.onShowRequests = onShowRequests
controller.onScrollOffsetChange = onScrollOffsetChange
controller.onMarkAsRead = onMarkAsRead
return controller
}
func updateUIViewController(_ controller: ChatListCollectionController, context: Context) {
// Update callbacks (closures may capture new state)
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.onTogglePin = onTogglePin
controller.onToggleMute = onToggleMute
controller.onPinnedStateChange = onPinnedStateChange
controller.onShowRequests = onShowRequests
controller.onScrollOffsetChange = onScrollOffsetChange
controller.onMarkAsRead = onMarkAsRead
// Skip data update if loading (shimmer is shown by SwiftUI)
guard !isLoading else { return }
// Update data and apply snapshot
controller.updateDialogs(
pinned: pinnedDialogs,
unpinned: unpinnedDialogs,
requestsCount: requestsCount,
typingDialogs: typingDialogs,
isSyncing: isSyncing
)
}
}

View File

@@ -0,0 +1,34 @@
import UIKit
// MARK: - PinnedSectionBackgroundView
/// Section-level decoration view for the pinned section.
/// Displays Telegram-exact pinned background color edge-to-edge behind all pinned rows.
/// Registered as a decoration item in UICollectionViewCompositionalLayout.
final class PinnedSectionBackgroundView: UICollectionReusableView {
static let elementKind = "pinned-section-background"
override init(frame: CGRect) {
super.init(frame: frame)
updateColor()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
updateColor()
}
}
private func updateColor() {
let isDark = traitCollection.userInterfaceStyle == .dark
backgroundColor = isDark
? UIColor(red: 0x1C/255, green: 0x1C/255, blue: 0x1D/255, alpha: 1) // Telegram: pinnedItemBackgroundColor
: UIColor(red: 0xF7/255, green: 0xF7/255, blue: 0xF7/255, alpha: 1)
}
}