Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов

This commit is contained in:
2026-03-13 00:12:30 +05:00
parent 70deaaf7f7
commit c7bea82c3a
30 changed files with 1245 additions and 270 deletions

View File

@@ -135,7 +135,7 @@ private extension ChatListSearchContent {
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.top, 20)
.padding(.bottom, 6)
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in

View File

@@ -56,7 +56,7 @@ struct ChatListView: View {
navigationState.path.append(route)
// Delay search dismissal so NavigationStack processes
// the push before the search overlay is removed.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isSearchActive = false
isSearchFocused = false
searchText = ""
@@ -92,6 +92,11 @@ struct ChatListView: View {
}
}
.tint(RosettaColors.figmaBlue)
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
guard let route = notification.object as? ChatRoute else { return }
// Navigate to the chat from push notification tap
navigationState.path = [route]
}
}
// MARK: - Cancel Search

View File

@@ -172,7 +172,9 @@ private extension ChatRowView {
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
// Desktop parity: delivery icon and unread badge are
// mutually exclusive badge hidden when lastMessageFromMe.
if dialog.unreadCount > 0 && !dialog.lastMessageFromMe {
unreadBadge
}
}

View File

@@ -4,35 +4,34 @@ import SwiftUI
/// Telegram-style skeleton loading for search results.
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
/// Uses TimelineView so the shimmer never restarts on view rebuild.
struct SearchSkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
TimelineView(.animation) { timeline in
let phase = shimmerPhase(from: timeline.date)
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index, phase: phase)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
}
}
}
}
}
.scrollDisabled(true)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
.scrollDisabled(true)
}
}
private func skeletonRow(index: Int) -> some View {
private func skeletonRow(index: Int, phase: CGFloat) -> some View {
HStack(spacing: 0) {
// Avatar 62pt circle matching Figma
Circle()
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: 62, height: 62)
.padding(.leading, 10)
.padding(.trailing, 10)
@@ -41,12 +40,12 @@ struct SearchSkeletonView: View {
VStack(alignment: .leading, spacing: 8) {
// Title line name width varies per row
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: titleWidth(for: index), height: 16)
// Subtitle line message preview
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: subtitleWidth(for: index), height: 14)
}
@@ -54,7 +53,7 @@ struct SearchSkeletonView: View {
// Trailing time placeholder
RoundedRectangle(cornerRadius: 3)
.fill(shimmerGradient)
.fill(shimmerGradient(phase: phase))
.frame(width: 40, height: 12)
.padding(.trailing, 16)
}
@@ -71,18 +70,6 @@ struct SearchSkeletonView: View {
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
return widths[index % widths.count]
}
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)
)
}
}
// MARK: - SearchSkeletonRow
@@ -90,44 +77,56 @@ struct SearchSkeletonView: View {
/// 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)
TimelineView(.animation) { timeline in
let phase = shimmerPhase(from: timeline.date)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 120, height: 14)
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient(phase: phase))
.frame(width: 48, height: 48)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 90, height: 12)
VStack(alignment: .leading, spacing: 6) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient(phase: phase))
.frame(width: 120, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient(phase: phase))
.frame(width: 90, height: 12)
}
Spacer()
}
Spacer()
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.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)
)
}
}
// MARK: - Shared Shimmer Helpers
/// Derives a 01 phase from the system clock (1.5s cycle).
/// Clock-based never resets on view rebuild.
private func shimmerPhase(from date: Date) -> CGFloat {
let elapsed = date.timeIntervalSinceReferenceDate
let cycle: Double = 1.5
return CGFloat(elapsed.truncatingRemainder(dividingBy: cycle) / cycle)
}
private func shimmerGradient(phase: CGFloat) -> LinearGradient {
// Map phase 01 to position -0.51.5 so the highlight
// enters from off-screen left and exits off-screen right.
// When phase wraps 10, highlight is already invisible no jump.
let position = phase * 2.0 - 0.5
return LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: position - 0.3, y: 0),
endPoint: UnitPoint(x: position + 0.3, y: 0)
)
}