Голосовые сообщения - анимация кнопки микрофона + панель записи с таймером
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
886
Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift
Normal file
886
Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift
Normal 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.0001→1.2 (0.2s) → 1.0 (0.12s settle);
|
||||
/// disappear = scale 1.0→0.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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user