Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
struct ChatDetailView: View {
|
||||
let route: ChatRoute
|
||||
@@ -17,7 +18,8 @@ struct ChatDetailView: View {
|
||||
@State private var sendError: String?
|
||||
@State private var isViewActive = false
|
||||
// markReadTask removed — read receipts no longer sent from .onChange(of: messages.count)
|
||||
@FocusState private var isInputFocused: Bool
|
||||
@State private var isInputFocused = false
|
||||
@StateObject private var keyboard = KeyboardTracker()
|
||||
|
||||
private var currentPublicKey: String {
|
||||
SessionManager.shared.currentPublicKey
|
||||
@@ -97,7 +99,11 @@ struct ChatDetailView: View {
|
||||
messagesList(maxBubbleWidth: max(min(geometry.size.width * 0.72, 380), 140))
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { composer }
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
composer
|
||||
.offset(y: keyboard.interactiveOffset)
|
||||
.animation(.spring(.smooth(duration: 0.32)), value: keyboard.interactiveOffset)
|
||||
}
|
||||
.background {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
@@ -124,8 +130,15 @@ struct ChatDetailView: View {
|
||||
guard isViewActive else { return }
|
||||
activateDialog()
|
||||
markDialogAsRead()
|
||||
// Clear delivered notifications from this sender
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Subscribe to opponent's online status (Android parity) — only after settled
|
||||
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
|
||||
// Desktop parity: force-refresh user info (incl. online status) on chat open.
|
||||
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
|
||||
if !route.isSavedMessages {
|
||||
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isViewActive = false
|
||||
@@ -177,14 +190,17 @@ private extension ChatDetailView {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
isTyping
|
||||
? RosettaColors.primaryBlue
|
||||
: (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
@@ -229,14 +245,17 @@ private extension ChatDetailView {
|
||||
Text(subtitleText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(
|
||||
isTyping || (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
isTyping
|
||||
? RosettaColors.primaryBlue
|
||||
: (dialog?.isOnline == true)
|
||||
? RosettaColors.online
|
||||
: RosettaColors.Adaptive.textSecondary
|
||||
)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
@@ -416,7 +435,7 @@ private extension ChatDetailView {
|
||||
.padding(.top, messagesTopInset)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onTapGesture { isInputFocused = false }
|
||||
.onAppear {
|
||||
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
|
||||
@@ -440,13 +459,8 @@ private extension ChatDetailView {
|
||||
guard focused else { return }
|
||||
// User tapped the input — reset idle timer.
|
||||
SessionManager.shared.recordUserInteraction()
|
||||
// Delay matches keyboard animation (~250ms) so scroll happens after layout settles.
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
scrollToBottom(proxy: proxy, animated: true)
|
||||
}
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
|
||||
scroll
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.scrollIndicators(.hidden)
|
||||
@@ -535,17 +549,16 @@ private extension ChatDetailView {
|
||||
.buttonStyle(ChatDetailGlassPressButtonStyle())
|
||||
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
TextField("Message", text: $messageText, axis: .vertical)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1...5)
|
||||
.focused($isInputFocused)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled()
|
||||
.padding(.leading, 6)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
ChatTextInput(
|
||||
text: $messageText,
|
||||
isFocused: $isInputFocused,
|
||||
onKeyboardHeightChange: { keyboard.updateFromKVO(keyboardHeight: $0) },
|
||||
onUserTextInsertion: handleComposerUserTyping,
|
||||
textColor: UIColor(RosettaColors.Adaptive.text),
|
||||
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
)
|
||||
.padding(.leading, 6)
|
||||
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
|
||||
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
Button { } label: {
|
||||
@@ -625,6 +638,7 @@ private extension ChatDetailView {
|
||||
.padding(.trailing, composerTrailingPadding)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 4)
|
||||
.simultaneousGesture(composerDismissGesture)
|
||||
.animation(composerAnimation, value: canSend)
|
||||
.animation(composerAnimation, value: shouldShowSendButton)
|
||||
}
|
||||
@@ -739,6 +753,17 @@ private extension ChatDetailView {
|
||||
else { isInputFocused = true }
|
||||
}
|
||||
|
||||
var composerDismissGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
guard isInputFocused else { return }
|
||||
let vertical = value.translation.height
|
||||
let horizontal = value.translation.width
|
||||
guard vertical > 12, abs(vertical) > abs(horizontal) else { return }
|
||||
isInputFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryTint(_ status: DeliveryStatus) -> Color {
|
||||
switch status {
|
||||
case .read: return Color(hex: 0xA4E2FF)
|
||||
@@ -852,6 +877,19 @@ private extension ChatDetailView {
|
||||
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
|
||||
}
|
||||
|
||||
/// Remove all delivered push notifications from this specific sender.
|
||||
func clearDeliveredNotifications(for senderKey: String) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let idsToRemove = delivered
|
||||
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
|
||||
.map { $0.request.identifier }
|
||||
if !idsToRemove.isEmpty {
|
||||
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCurrentMessage() {
|
||||
let message = trimmedMessage
|
||||
guard !message.isEmpty else { return }
|
||||
@@ -877,6 +915,10 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
func handleComposerUserTyping() {
|
||||
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
|
||||
}
|
||||
|
||||
static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
@@ -1139,7 +1181,7 @@ private struct SVGPathParser {
|
||||
while index < tokens.count {
|
||||
if case .command = tokens[index] { return }
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0→1 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 0→1 to position -0.5→1.5 so the highlight
|
||||
// enters from off-screen left and exits off-screen right.
|
||||
// When phase wraps 1→0, 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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ struct SearchView: View {
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🔵 SearchView.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ZStack(alignment: .bottom) {
|
||||
RosettaColors.Adaptive.background
|
||||
@@ -143,8 +145,10 @@ private struct FavoriteContactsRow: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟠 FavoriteContactsRow.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||
if !dialogs.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -191,8 +195,10 @@ private struct RecentSection: View {
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟤 RecentSection.body #\(Self._bodyCount)")
|
||||
#endif
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user