бейдж упоминаний в чат-листе, прямая навигация по @mention, тап на аватарку → профиль, RequestChats на UIKit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.0001→1.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
|
||||
|
||||
Reference in New Issue
Block a user