Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе

This commit is contained in:
2026-03-10 19:31:09 +05:00
parent 2cc780201d
commit 0f5094df10
17 changed files with 929 additions and 202 deletions

View File

@@ -23,10 +23,16 @@ struct ChatListSearchContent: View {
// MARK: - Active Search (Three States)
private extension ChatListSearchContent {
/// Android-style: skeleton empty results only one visible at a time.
/// Desktop-parity: skeleton empty results only one visible at a time.
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
/// to avoid @Published re-render cascade through ChatListView.
@ViewBuilder
var activeSearchContent: some View {
let localResults = viewModel.filteredDialogs
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
// Local results: match by username ONLY (desktop parity server matches usernames)
let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in
!query.isEmpty && dialog.opponentUsername.lowercased().contains(query)
}
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
@@ -63,6 +69,7 @@ private extension ChatListSearchContent {
}
/// Scrollable list of local dialogs + server results.
/// Shows skeleton rows at the bottom while server is still searching.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
@@ -84,11 +91,23 @@ private extension ChatListSearchContent {
}
}
// Skeleton loading rows while server search in progress
if viewModel.isServerSearching {
searchSkeletonRows
}
Spacer().frame(height: 80)
}
}
.scrollDismissesKeyboard(.immediately)
}
/// Inline skeleton rows (3 shimmer placeholders) shown below existing results.
private var searchSkeletonRows: some View {
ForEach(0..<3, id: \.self) { _ in
SearchSkeletonRow()
}
}
}
// MARK: - Recent Searches

View File

@@ -29,6 +29,7 @@ struct ChatListView: View {
@StateObject private var viewModel = ChatListViewModel()
@StateObject private var navigationState = ChatListNavigationState()
@State private var searchText = ""
@State private var hasPinnedChats = false
@FocusState private var isSearchFocused: Bool
@MainActor static var _bodyCount = 0
@@ -42,6 +43,12 @@ struct ChatListView: View {
.padding(.horizontal, 16)
.padding(.top, 6)
.padding(.bottom, 8)
.background(
(hasPinnedChats && !isSearchActive
? RosettaColors.Dark.pinnedSectionBackground
: Color.clear
).ignoresSafeArea(.all, edges: .top)
)
if isSearchActive {
ChatListSearchContent(
@@ -68,7 +75,7 @@ struct ChatListView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
.toolbar { toolbarContent }
.toolbarBackground(.hidden, for: .navigationBar)
.modifier(ChatListToolbarBackgroundModifier())
.onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue)
}
@@ -223,7 +230,14 @@ private extension ChatListView {
// without polluting ChatListView's observation scope.
ChatListDialogContent(
viewModel: viewModel,
navigationState: navigationState
navigationState: navigationState,
onPinnedStateChange: { pinned in
if hasPinnedChats != pinned {
withAnimation(.easeInOut(duration: 0.25)) {
hasPinnedChats = pinned
}
}
}
)
}
}
@@ -235,60 +249,112 @@ private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
if !isSearchActive {
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.glassCapsule()
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
// Isolated view reads AccountManager & SessionManager (@Observable)
// without polluting ChatListView's observation scope.
ToolbarStoriesAvatar()
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 0) {
if #available(iOS 26, *) {
// iOS 26+ original compact toolbar (no capsules, system icons)
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Image("toolbar-add-chat")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.frame(width: 44, height: 44)
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
ToolbarStoriesAvatar()
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
HStack(spacing: 8) {
Button { } label: {
Image(systemName: "camera")
.font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button { } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.padding(.bottom, 2)
.accessibilityLabel("New chat")
}
}
} else {
// iOS < 26 capsule-styled toolbar with custom icons
ToolbarItem(placement: .navigationBarLeading) {
Button { } label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(height: 44)
.padding(.horizontal, 10)
}
.buttonStyle(.plain)
.accessibilityLabel("Add chat")
Button { } label: {
Image("toolbar-compose")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.accessibilityLabel("New chat")
.glassCapsule()
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
ToolbarStoriesAvatar()
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 0) {
Button { } label: {
Image("toolbar-add-chat")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.accessibilityLabel("Add chat")
Button { } label: {
Image("toolbar-compose")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.accessibilityLabel("New chat")
}
.foregroundStyle(RosettaColors.Adaptive.text)
.glassCapsule()
}
.foregroundStyle(RosettaColors.Adaptive.text)
.glassCapsule()
}
}
}
}
// MARK: - Toolbar Background Modifier
private struct ChatListToolbarBackgroundModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.toolbarBackground(.visible, for: .navigationBar)
.applyGlassNavBar()
} else {
content
.toolbarBackground(.hidden, for: .navigationBar)
}
}
}
// MARK: - Toolbar Stories Avatar (observation-isolated)
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
@@ -341,15 +407,21 @@ private struct DeviceVerificationBannersContainer: View {
private struct ChatListDialogContent: View {
@ObservedObject var viewModel: ChatListViewModel
@ObservedObject var navigationState: ChatListNavigationState
var onPinnedStateChange: (Bool) -> Void = { _ in }
@MainActor static var _bodyCount = 0
var body: some View {
let _ = Self._bodyCount += 1
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
let hasPinned = !viewModel.pinnedDialogs.isEmpty
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
} else {
dialogList
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
.onAppear { onPinnedStateChange(hasPinned) }
}
}
@@ -366,7 +438,8 @@ private struct ChatListDialogContent: View {
if !viewModel.pinnedDialogs.isEmpty {
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
chatRow(dialog, isFirst: index == 0)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
.environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground)
.listRowBackground(RosettaColors.Dark.pinnedSectionBackground)
}
}
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
@@ -397,11 +470,6 @@ 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: {
Label("Delete", systemImage: "trash")
}
Button {
viewModel.toggleMute(dialog)
} label: {
@@ -410,7 +478,7 @@ private struct ChatListDialogContent: View {
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(.indigo)
.tint(dialog.isMuted ? .green : .indigo)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
@@ -422,7 +490,7 @@ private struct ChatListDialogContent: View {
Button {
viewModel.togglePin(dialog)
} label: {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin")
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}

View File

@@ -12,14 +12,15 @@ final class ChatListViewModel: ObservableObject {
// MARK: - State
@Published var isLoading = false
@Published var searchQuery = ""
/// NOT @Published avoids 2× body re-renders per keystroke in ChatListView.
/// Local filtering uses `searchText` param directly in ChatListSearchContent.
var searchQuery = ""
@Published var serverSearchResults: [SearchUser] = []
@Published var isServerSearching = false
@Published var recentSearches: [RecentSearch] = []
private var searchTask: Task<Void, Never>?
private var searchRetryTask: Task<Void, Never>?
private var lastSearchedText = ""
private var searchHandlerToken: UUID?
private var recentSearchesCancellable: AnyCancellable?
private let recentRepository = RecentSearchesRepository.shared
@@ -32,19 +33,13 @@ final class ChatListViewModel: ObservableObject {
}
// MARK: - Computed (local dialog filtering)
// MARK: - Computed (dialog list for ChatListDialogContent)
/// Full dialog list used by ChatListDialogContent which is only visible
/// when search is NOT active. Search filtering is done separately in
/// ChatListSearchContent using `searchText` parameter directly.
var filteredDialogs: [Dialog] {
var result = DialogRepository.shared.sortedDialogs
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
if !query.isEmpty {
result = result.filter {
$0.opponentTitle.lowercased().contains(query)
|| $0.opponentUsername.lowercased().contains(query)
|| $0.lastMessage.lowercased().contains(query)
}
}
return result
DialogRepository.shared.sortedDialogs
}
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
@@ -120,17 +115,19 @@ final class ChatListViewModel: ObservableObject {
private func triggerServerSearch() {
searchTask?.cancel()
searchTask = nil
searchRetryTask?.cancel()
searchRetryTask = nil
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
serverSearchResults = []
isServerSearching = false
lastSearchedText = ""
// Guard: only publish if value actually changes (avoids extra re-renders)
if !serverSearchResults.isEmpty { serverSearchResults = [] }
if isServerSearching { isServerSearching = false }
return
}
if trimmed == lastSearchedText { return }
isServerSearching = true
// Guard: don't re-publish true when already true
if !isServerSearching { isServerSearching = true }
searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
@@ -139,36 +136,45 @@ final class ChatListViewModel: ObservableObject {
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
guard connState == .authenticated, let hash else {
self.isServerSearching = false
// Reset so next attempt re-sends instead of being de-duped
self.lastSearchedText = ""
// Retry after 2 seconds if still have a query
self.scheduleSearchRetry()
return
}
self.lastSearchedText = currentQuery
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
ProtocolManager.shared.sendPacket(packet)
self.sendSearchPacket(query: currentQuery)
}
}
private func scheduleSearchRetry() {
searchRetryTask?.cancel()
searchRetryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self, !Task.isCancelled else { return }
let q = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return }
self.triggerServerSearch()
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
private func sendSearchPacket(query: String) {
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
guard connState == .authenticated, let hash else {
// Not authenticated wait for reconnect then send
Self.logger.debug("Search deferred — waiting for authentication")
searchRetryTask?.cancel()
searchRetryTask = Task { [weak self] in
// Poll every 500ms for up to 10s (covers 5s reconnect + handshake)
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(500))
guard let self, !Task.isCancelled else { return }
let current = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard current == query else { return } // Query changed, abort
if ProtocolManager.shared.connectionState == .authenticated {
Self.logger.debug("Connection restored — sending pending search")
self.sendSearchPacket(query: query)
return
}
}
// Timed out
guard let self else { return }
Self.logger.warning("Search timed out waiting for authentication")
self.isServerSearching = false
}
return
}
var packet = PacketSearch()
packet.privateKey = hash
packet.search = query
Self.logger.debug("📤 Sending search packet for '\(query)'")
ProtocolManager.shared.sendPacket(packet)
}
private func normalizeSearchInput(_ input: String) -> String {

View File

@@ -173,17 +173,27 @@ private extension ChatRowView {
}
}
/// Desktop parity: clock only within 80s of send, then error.
/// Delivered single check, Read double checks.
private static let maxWaitingSeconds: TimeInterval = 80
@ViewBuilder
var deliveryIcon: some View {
switch dialog.lastMessageDelivered {
case .waiting:
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
if isWithinWaitingWindow {
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
} else {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
case .delivered:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.foregroundStyle(RosettaColors.figmaBlue)
case .read:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
@@ -202,6 +212,12 @@ private extension ChatRowView {
}
}
private var isWithinWaitingWindow: Bool {
guard dialog.lastMessageTimestamp > 0 else { return true }
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
return Date().timeIntervalSince(sentDate) < Self.maxWaitingSeconds
}
var unreadBadge: some View {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")

View File

@@ -84,3 +84,50 @@ struct SearchSkeletonView: View {
)
}
}
// MARK: - SearchSkeletonRow
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
/// Used inline below existing search results while server is still loading.
struct SearchSkeletonRow: View {
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 48, height: 48)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}