Кнопка быстрого скролла вниз, автоскролл при отправке сообщения, оптимизация FPS анимации клавиатуры

This commit is contained in:
2026-03-14 01:56:48 +05:00
parent 7dbddb27a6
commit acc3fb8e2f
10 changed files with 451 additions and 215 deletions

View File

@@ -420,16 +420,22 @@ private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onPinnedStateChange: (Bool) -> Void = { _ in }
/// Desktop parity: track typing dialogs from MessageRepository (@Published).
@State private var typingDialogs: Set<String> = []
var body: some View {
let hasPinned = !viewModel.pinnedDialogs.isEmpty
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
} else {
dialogList
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
.onReceive(MessageRepository.shared.$typingDialogs) { typingDialogs = $0 }
}
}
@@ -466,10 +472,42 @@ private struct ChatListDialogContent: View {
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
Button {
navigationState.path.append(ChatRoute(dialog: dialog))
} label: {
ChatRowView(dialog: dialog)
/// Desktop parity: wrap in SyncAwareChatRow to isolate @Observable read
/// of SessionManager.syncBatchInProgress from this view's observation scope.
SyncAwareChatRow(
dialog: dialog,
isTyping: typingDialogs.contains(dialog.opponentKey),
isFirst: isFirst,
onTap: { navigationState.path.append(ChatRoute(dialog: dialog)) },
onDelete: { withAnimation { viewModel.deleteDialog(dialog) } },
onToggleMute: { viewModel.toggleMute(dialog) },
onTogglePin: { viewModel.togglePin(dialog) }
)
}
}
// 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.
private struct SyncAwareChatRow: View {
let dialog: Dialog
let isTyping: Bool
let isFirst: Bool
let onTap: () -> Void
let onDelete: () -> Void
let onToggleMute: () -> Void
let onTogglePin: () -> Void
var body: some View {
let isSyncing = SessionManager.shared.syncBatchInProgress
Button(action: onTap) {
ChatRowView(
dialog: dialog,
isSyncing: isSyncing,
isTyping: isTyping
)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
@@ -478,16 +516,12 @@ private struct ChatListDialogContent: View {
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation { viewModel.deleteDialog(dialog) }
} label: {
Button(role: .destructive, action: onDelete) {
Label("Delete", systemImage: "trash")
}
if !dialog.isSavedMessages {
Button {
viewModel.toggleMute(dialog)
} label: {
Button(action: onToggleMute) {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
@@ -497,9 +531,7 @@ private struct ChatListDialogContent: View {
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
viewModel.togglePin(dialog)
} label: {
Button(action: onTogglePin) {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)

View File

@@ -20,6 +20,10 @@ import Combine
/// 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
/// Desktop parity: recheck delivery timeout every 40s so clock error
/// transitions happen automatically without user scrolling.
@@ -123,12 +127,20 @@ private extension ChatRowView {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.foregroundStyle(
isTyping && !dialog.isSavedMessages
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
.lineLimit(2)
.frame(height: 41, alignment: .topLeading)
}
var messageText: String {
// Desktop parity: show "typing..." in chat list row when opponent is typing.
if isTyping && !dialog.isSavedMessages {
return "typing..."
}
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
@@ -174,7 +186,9 @@ private extension ChatRowView {
// Desktop parity: delivery icon and unread badge are
// mutually exclusive badge hidden when lastMessageFromMe.
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe {
// Also hidden during sync (desktop hides badges while
// protocolState == SYNCHRONIZATION).
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe && !isSyncing {
unreadBadge
}
}
@@ -299,6 +313,7 @@ private extension ChatRowView {
VStack(spacing: 0) {
ChatRowView(dialog: sampleDialog)
ChatRowView(dialog: sampleDialog, isTyping: true)
}
.background(RosettaColors.Adaptive.background)
}