Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, 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

@@ -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
}
}
}
}