Кнопка быстрого скролла вниз, автоскролл при отправке сообщения, оптимизация FPS анимации клавиатуры
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user