бейдж упоминаний в чат-листе, прямая навигация по @mention, тап на аватарку → профиль, RequestChats на UIKit

This commit is contained in:
2026-04-12 21:40:32 +05:00
parent 86a400b543
commit 30f333ef90
77 changed files with 6346 additions and 1362 deletions

View File

@@ -647,7 +647,9 @@ private struct ChatListDialogContent: View {
@State private var typingDialogs: [String: Set<String>] = [:]
var body: some View {
#if DEBUG
let _ = PerformanceLogger.shared.track("chatList.bodyEval")
#endif
// CRITICAL: Read DialogRepository.dialogs directly to establish @Observable tracking.
// Without this, ChatListDialogContent only observes viewModel (ObservableObject)
// which never publishes objectWillChange for dialog mutations.
@@ -725,78 +727,6 @@ private struct ChatListDialogContent: View {
}
}
// MARK: - Sync-Aware Chat Row (observation-isolated)
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
/// Reads `SessionManager.syncBatchInProgress` (@Observable) in its own
/// observation scope. Without this wrapper, every sync state change would
/// invalidate the entire `ChatListDialogContent.body` and rebuild all rows.
///
/// **Performance:** `viewModel` and `navigationState` are stored as plain `let`
/// (not @ObservedObject). Class references compare by pointer in SwiftUI's
/// memcmp-based view diffing stable pointers mean unchanged rows are NOT
/// re-evaluated when the parent body rebuilds. Closures are defined inline
/// (not passed from parent) to avoid non-diffable closure props that force
/// every row dirty on every parent re-render.
struct SyncAwareChatRow: View {
let dialog: Dialog
let isTyping: Bool
let typingSenderNames: [String]
let isFirst: Bool
let viewModel: ChatListViewModel
let navigationState: ChatListNavigationState
var body: some View {
let isSyncing = SessionManager.shared.syncBatchInProgress
Button {
navigationState.path.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(
dialog: dialog,
isSyncing: isSyncing,
isTyping: isTyping,
typingSenderNames: typingSenderNames
)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
.listRowSeparator(.visible, edges: .bottom)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation { viewModel.deleteDialog(dialog) }
} label: {
Label("Delete", systemImage: "trash")
}
if !dialog.isSavedMessages {
Button {
withAnimation { viewModel.toggleMute(dialog) }
} label: {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(dialog.isMuted ? .green : .indigo)
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
withAnimation { viewModel.togglePin(dialog) }
} label: {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
}
}
// MARK: - Device Approval Banner
/// Desktop parity: clean banner with "New login from {device} ({os})" and Accept/Decline.

View File

@@ -1,459 +0,0 @@
import SwiftUI
import Combine
// MARK: - ChatRowView
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
///
/// Row: height 78, pl-10, pr-16, items-center
/// Avatar: 62px circle, pr-10
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
/// Title: gap-4, items-center SF Pro Medium 17/22, tracking -0.43
/// Message: h-41 SF Pro Regular 15/20, tracking -0.23, secondary
/// Accessories: h-full, items-center, justify-end
/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8
/// Time: SF Pro Regular 14/20, tracking -0.23, secondary
/// Other: flex-1, items-end, justify-end, pb-14
/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full
/// SF Pro Regular 15/20, black, tracking -0.23
struct ChatRowView: View {
let dialog: Dialog
/// Desktop parity: suppress unread badge during sync.
var isSyncing: Bool = false
/// Desktop parity: show "typing..." instead of last message.
var isTyping: Bool = false
/// Group typing: sender names for "Name typing..." / "Name and N typing..." display.
var typingSenderNames: [String] = []
var displayTitle: 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))
}
var body: some View {
let _ = PerformanceLogger.shared.track("chatRow.bodyEval")
HStack(spacing: 0) {
avatarSection
.padding(.trailing, 10)
contentSection
}
.padding(.leading, 10)
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
}
}
// MARK: - Avatar
/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own
/// scope so only the avatar re-renders when opponent avatar changes not the
/// entire ChatRowView (title, message preview, badge, etc.).
private struct ChatRowAvatar: View {
let dialog: Dialog
var body: some View {
if dialog.isGroup {
groupAvatarView
} else {
directAvatarView
}
}
private var directAvatarView: some View {
// Establish @Observable tracking re-renders this view on avatar save/remove.
let _ = AvatarRepository.shared.avatarVersion
return AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages,
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
}
private var groupAvatarView: some View {
let _ = AvatarRepository.shared.avatarVersion
let groupImage = AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
return ZStack {
if let image = groupImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 62, height: 62)
.clipShape(Circle())
} else {
Circle()
.fill(RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count].tint)
.frame(width: 62, height: 62)
Image(systemName: "person.2.fill")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
}
}
}
private extension ChatRowView {
var avatarSection: some View {
ChatRowAvatar(dialog: dialog)
}
}
// MARK: - Content Section
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
// "Title and Trailing Accessories": flex-1, gap-6, items-center
private extension ChatRowView {
var contentSection: some View {
HStack(alignment: .center, spacing: 6) {
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
VStack(alignment: .leading, spacing: 0) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63)
.clipped()
// "Accessories and Grabber": h-full, items-center, justify-end
trailingColumn
.frame(maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
.padding(.bottom, 1)
}
}
// MARK: - Title Row (name + badges)
// Figma "Title": gap-4, items-center, w-full
private extension ChatRowView {
var titleRow: some View {
HStack(spacing: 4) {
Text(displayTitle)
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 16
)
}
if dialog.isMuted {
Image(systemName: "speaker.slash.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
}
}
// MARK: - Message Row
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
private extension ChatRowView {
var messageRow: some View {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(
isTyping && !dialog.isSavedMessages
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(2)
.frame(height: 41, alignment: .topLeading)
}
/// Static cache for emoji-parsed message text (avoids regex per row per render).
private static var messageTextCache: [String: String] = [:]
var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages {
if dialog.isGroup && !typingSenderNames.isEmpty {
if typingSenderNames.count == 1 {
return "\(typingSenderNames[0]) typing..."
} else {
return "\(typingSenderNames[0]) and \(typingSenderNames.count - 1) typing..."
}
}
return "typing..."
}
let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty {
return "No messages yet"
}
// Desktop parity: show "Group invite" for #group: invite messages.
if raw.hasPrefix("#group:") {
return "Group invite"
}
// Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user.
// This catches stale data persisted before isGarbageText was improved.
if Self.looksLikeCiphertext(raw) {
return "No messages yet"
}
if let cached = Self.messageTextCache[dialog.lastMessage] {
return cached
}
// Strip inline markdown markers and convert emoji shortcodes for clean preview.
let cleaned = dialog.lastMessage.replacingOccurrences(of: "**", with: "")
let result = EmojiParser.replaceShortcodes(in: cleaned)
if Self.messageTextCache.count > 500 {
let keysToRemove = Array(Self.messageTextCache.keys.prefix(250))
for key in keysToRemove { Self.messageTextCache.removeValue(forKey: key) }
}
Self.messageTextCache[dialog.lastMessage] = result
return result
}
/// Detects encrypted payload formats that should never be shown in UI.
private static func looksLikeCiphertext(_ text: String) -> Bool {
// CHNK: chunked format
if text.hasPrefix("CHNK:") { return true }
// ivBase64:ctBase64 or hex-encoded XChaCha20 ciphertext
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 }
}
// Pure hex string (40 chars, only hex digits) XChaCha20 wire format
if text.count >= 40 {
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
if text.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
}
return false
}
}
// MARK: - Trailing Column
// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8
// "Read Status and Time": gap-2, items-center
// "Other": flex-1, items-end, justify-end, pb-14
private extension ChatRowView {
var trailingColumn: some View {
VStack(alignment: .trailing, spacing: 0) {
// Top: read status + time
HStack(spacing: 2) {
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.tracking(-0.23)
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 8)
Spacer(minLength: 0)
// Bottom: pin or unread badge
HStack(spacing: 8) {
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
// Show unread badge whenever there are unread messages.
// Previously hidden when lastMessageFromMe (desktop parity),
// but this caused invisible unreads when user sent a reply
// without reading prior incoming messages first.
if dialog.hasMention && dialog.unreadCount > 0 && !isSyncing {
mentionBadge
}
if dialog.unreadCount > 0 && !isSyncing {
unreadBadge
}
}
.padding(.bottom, 14)
}
}
/// Telegram-style `@` mention indicator (shown left of unread count).
var mentionBadge: some View {
Text("@")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background {
Circle()
.fill(dialog.isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
}
}
@ViewBuilder
var deliveryIcon: some View {
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
} else {
switch dialog.lastMessageDelivered {
case .waiting:
// Timer isolated to sub-view only .waiting rows create a timer.
DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
case .delivered:
SingleCheckmarkShape()
.fill(RosettaColors.Adaptive.textSecondary)
.frame(width: 14, height: 10.3)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
}
var unreadBadge: some View {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
let isMuted = dialog.isMuted
let isSmall = count < 10
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, isSmall ? 0 : 4)
.frame(
minWidth: 20,
maxWidth: isSmall ? 20 : 37,
minHeight: 20
)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
}
}
}
// MARK: - Delivery Waiting Icon (timer-isolated)
/// Desktop parity: clock error after 80s. Timer only exists on rows with
/// `.waiting` delivery status all other rows have zero timer overhead.
private struct DeliveryWaitingIcon: View {
let sentTimestamp: Int64
@State private var now = Date()
private let recheckTimer = Timer.publish(every: 40, on: .main, in: .common).autoconnect()
private var isWithinWindow: Bool {
guard sentTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(sentTimestamp) / 1000)
return now.timeIntervalSince(sentDate) < 80
}
var body: some View {
Group {
if isWithinWindow {
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} else {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
.onReceive(recheckTimer) { now = $0 }
}
}
// MARK: - Time Formatting
private extension ChatRowView {
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
}()
/// Static cache for formatted time strings (avoids Date/Calendar per row per render).
private static var timeStringCache: [Int64: String] = [:]
var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" }
if let cached = Self.timeStringCache[dialog.lastMessageTimestamp] {
return cached
}
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
let now = Date()
let calendar = Calendar.current
let result: String
if calendar.isDateInToday(date) {
result = Self.timeFormatter.string(from: date)
} else if calendar.isDateInYesterday(date) {
result = "Yesterday"
} else if let days = calendar.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 keysToRemove = Array(Self.timeStringCache.keys.prefix(250))
for key in keysToRemove { Self.timeStringCache.removeValue(forKey: key) }
}
Self.timeStringCache[dialog.lastMessageTimestamp] = result
return result
}
}
// MARK: - Preview
#Preview {
let sampleDialog = Dialog(
id: "preview", account: "mykey", opponentKey: "abc001",
opponentTitle: "Alice Johnson",
opponentUsername: "alice",
lastMessage: "Hey, how are you?",
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
unreadCount: 3, isOnline: true, lastSeen: 0,
verified: 1, iHaveSent: true,
isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .delivered,
lastMessageRead: true
)
VStack(spacing: 0) {
ChatRowView(dialog: sampleDialog)
ChatRowView(dialog: sampleDialog, isTyping: true)
}
.background(RosettaColors.Adaptive.background)
}

View File

@@ -1,34 +1,33 @@
import Lottie
import SwiftUI
import UIKit
// MARK: - RequestChatsView (SwiftUI shell toolbar + navigation only)
/// Screen showing incoming message requests opened from the "Request Chats"
/// row at the top of the main chat list (Telegram Archive style).
/// List content rendered by UIKit RequestChatsController for performance parity.
struct RequestChatsView: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
@Environment(\.dismiss) private var dismiss
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: [String: Set<String>] = [:]
var body: some View {
Group {
if viewModel.requestsModeDialogs.isEmpty {
RequestsEmptyStateView()
} else {
List {
ForEach(Array(viewModel.requestsModeDialogs.enumerated()), id: \.element.id) { index, dialog in
requestRow(dialog, isFirst: index == 0)
let isSyncing = SessionManager.shared.syncBatchInProgress
RequestChatsCollectionView(
dialogs: viewModel.requestsModeDialogs,
isSyncing: isSyncing,
onSelectDialog: { dialog in
navigationState.path.append(ChatRoute(dialog: dialog))
},
onDeleteDialog: { dialog in
viewModel.deleteDialog(dialog)
}
Color.clear.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.hidden)
)
}
}
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
@@ -50,7 +49,6 @@ struct RequestChatsView: View {
}
.modifier(ChatListToolbarBackgroundModifier())
.enableSwipeBack()
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
}
// MARK: - Capsule Back Button (matches ChatDetailView)
@@ -67,30 +65,173 @@ struct RequestChatsView: View {
.frame(height: 44)
.padding(.horizontal, 4)
.background {
glassCapsule(strokeOpacity: 0.22, strokeColor: .white)
TelegramGlassCapsule()
}
}
}
// MARK: - RequestChatsCollectionView (UIViewControllerRepresentable bridge)
private struct RequestChatsCollectionView: UIViewControllerRepresentable {
let dialogs: [Dialog]
let isSyncing: Bool
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
func makeUIViewController(context: Context) -> RequestChatsController {
let controller = RequestChatsController()
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.updateDialogs(dialogs, isSyncing: isSyncing)
return controller
}
func updateUIViewController(_ controller: RequestChatsController, context: Context) {
controller.onSelectDialog = onSelectDialog
controller.onDeleteDialog = onDeleteDialog
controller.updateDialogs(dialogs, isSyncing: isSyncing)
}
}
// MARK: - RequestChatsController (UIKit)
/// Pure UIKit UICollectionView controller for request chats list.
/// Single flat section with ChatListCell same rendering as main chat list.
final class RequestChatsController: UIViewController {
var onSelectDialog: ((Dialog) -> Void)?
var onDeleteDialog: ((Dialog) -> Void)?
private var dialogs: [Dialog] = []
private var isSyncing: Bool = false
private var dialogMap: [String: Dialog] = [:]
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
private var cellRegistration: UICollectionView.CellRegistration<ChatListCell, Dialog>!
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
setupCollectionView()
setupCellRegistration()
setupDataSource()
}
// MARK: - Collection View
private func setupCollectionView() {
var listConfig = UICollectionLayoutListConfiguration(appearance: .plain)
listConfig.showsSeparators = false
listConfig.backgroundColor = .clear
listConfig.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.trailingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout.list(using: listConfig)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
collectionView.delegate = self
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
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 setupCellRegistration() {
cellRegistration = UICollectionView.CellRegistration<ChatListCell, Dialog> {
[weak self] cell, indexPath, dialog in
guard let self else { return }
cell.configure(with: dialog, isSyncing: self.isSyncing)
cell.setSeparatorHidden(indexPath.item == 0)
}
}
// Use TelegramGlass* for ALL iOS versions SwiftUI .glassEffect() blocks touches.
private func glassCapsule(strokeOpacity: Double = 0.18, strokeColor: Color = .white) -> some View {
TelegramGlassCapsule()
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Int, String>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, itemId in
guard let self, let dialog = self.dialogMap[itemId] else {
return UICollectionViewCell()
}
return collectionView.dequeueConfiguredReusableCell(
using: self.cellRegistration,
for: indexPath,
item: dialog
)
}
}
private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View {
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
)
// MARK: - Update Data
func updateDialogs(_ newDialogs: [Dialog], isSyncing: Bool) {
self.isSyncing = isSyncing
let oldIds = dialogs.map(\.id)
let newIds = newDialogs.map(\.id)
let structureChanged = oldIds != newIds
self.dialogs = newDialogs
dialogMap.removeAll(keepingCapacity: true)
for d in newDialogs { dialogMap[d.id] = d }
guard dataSource != nil else { return }
if structureChanged {
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
snapshot.appendItems(newIds, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: true)
}
// Reconfigure visible cells
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell),
let itemId = dataSource.itemIdentifier(for: indexPath),
let chatCell = cell as? ChatListCell,
let dialog = dialogMap[itemId] else { continue }
chatCell.configure(with: dialog, isSyncing: isSyncing)
}
}
// MARK: - Swipe Actions
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let itemId = dataSource.itemIdentifier(for: indexPath),
let dialog = dialogMap[itemId] else { return nil }
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)
return UISwipeActionsConfiguration(actions: [delete])
}
}
// MARK: - UICollectionViewDelegate
extension RequestChatsController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let itemId = dataSource.itemIdentifier(for: indexPath),
let dialog = dialogMap[itemId] else { return }
DispatchQueue.main.async { [weak self] in
self?.onSelectDialog?(dialog)
}
}
}

View File

@@ -58,8 +58,7 @@ final class ChatListCell: UICollectionViewCell {
let statusImageView = UIImageView()
let badgeContainer = UIView()
let badgeLabel = UILabel()
let mentionBadgeContainer = UIView()
let mentionLabel = UILabel()
let mentionImageView = UIImageView()
let pinnedIconView = UIImageView()
// Separator
@@ -173,16 +172,11 @@ final class ChatListCell: UICollectionViewCell {
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)
// Mention badge (Telegram-exact: tinted vector icon)
mentionImageView.image = UIImage(named: "MentionBadgeIcon")?.withRenderingMode(.alwaysTemplate)
mentionImageView.contentMode = .scaleAspectFit
mentionImageView.isHidden = true
contentView.addSubview(mentionImageView)
// Pin icon
pinnedIconView.contentMode = .scaleAspectFit
@@ -310,13 +304,12 @@ final class ChatListCell: UICollectionViewCell {
badgeRightEdge = badgeContainer.frame.minX - CellLayout.badgeSpacing
}
if !mentionBadgeContainer.isHidden {
mentionBadgeContainer.frame = CGRect(
if !mentionImageView.isHidden {
mentionImageView.frame = CGRect(
x: badgeRightEdge - CellLayout.badgeDiameter, y: badgeY,
width: CellLayout.badgeDiameter, height: CellLayout.badgeDiameter
)
mentionLabel.frame = mentionBadgeContainer.bounds
badgeRightEdge = mentionBadgeContainer.frame.minX - CellLayout.badgeSpacing
badgeRightEdge = mentionImageView.frame.minX - CellLayout.badgeSpacing
}
if !pinnedIconView.isHidden {
@@ -420,7 +413,7 @@ final class ChatListCell: UICollectionViewCell {
// Date
dateLabel.text = formatTime(dialog.lastMessageTimestamp)
dateLabel.textColor = (dialog.unreadCount > 0 && !dialog.isMuted) ? accentBlue : secondaryColor
dateLabel.textColor = secondaryColor
// Delivery status
configureDeliveryStatus(dialog: dialog, secondaryColor: secondaryColor, accentBlue: accentBlue)
@@ -584,7 +577,9 @@ final class ChatListCell: UICollectionViewCell {
private func configureBadge(dialog: Dialog, isSyncing: Bool, accentBlue: UIColor, mutedBadgeBg: UIColor) {
let count = dialog.unreadCount
let showBadge = count > 0 && !isSyncing
// Telegram: when mention + only 1 unread show only @ badge, no count
let showMention = dialog.hasMention && count > 0 && !isSyncing
let showBadge = count > 0 && !isSyncing && !(showMention && count == 1)
if showBadge {
let text: String
@@ -598,12 +593,11 @@ final class ChatListCell: UICollectionViewCell {
// Animate badge appear/disappear (Telegram: scale spring)
animateBadgeTransition(view: badgeContainer, shouldShow: showBadge, wasVisible: &wasBadgeVisible)
// Mention badge
let showMention = dialog.hasMention && count > 0 && !isSyncing
// Mention badge (Telegram: tinted vector icon)
if showMention {
mentionBadgeContainer.backgroundColor = dialog.isMuted ? mutedBadgeBg : accentBlue
mentionImageView.tintColor = dialog.isMuted ? mutedBadgeBg : accentBlue
}
animateBadgeTransition(view: mentionBadgeContainer, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
animateBadgeTransition(view: mentionImageView, shouldShow: showMention, wasVisible: &wasMentionBadgeVisible)
}
/// Telegram badge animation: appear = scale 0.00011.2 (0.2s) 1.0 (0.12s settle);
@@ -777,7 +771,7 @@ final class ChatListCell: UICollectionViewCell {
mutedIconView.isHidden = true
statusImageView.isHidden = true
badgeContainer.isHidden = true
mentionBadgeContainer.isHidden = true
mentionImageView.isHidden = true
pinnedIconView.isHidden = true
onlineIndicator.isHidden = true
contentView.backgroundColor = .clear
@@ -788,7 +782,7 @@ final class ChatListCell: UICollectionViewCell {
wasBadgeVisible = false
wasMentionBadgeVisible = false
badgeContainer.transform = .identity
mentionBadgeContainer.transform = .identity
mentionImageView.transform = .identity
}
// MARK: - Highlight