From 88126c867395c84360c6880da3c8715f90ef7587 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 17 Apr 2026 07:08:04 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=BE=D1=80=D0=B2=D0=B0=D1=80=D0=B4:=20p?= =?UTF-8?q?review=20bar=20=D0=B2=20composer=20+=20=D0=BD=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=20=D1=86=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20=D1=87=D0=B0=D1=82=20+=20Mantine=20?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta/Core/Services/SessionManager.swift | 47 +++++++++-- .../ChatDetail/ChatDetailViewController.swift | 24 ++++-- .../Chats/ChatDetail/NativeMessageCell.swift | 78 ++++++++++++++----- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 364e268..9f2809d 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1268,28 +1268,37 @@ final class SessionManager { /// Android parity: sends read receipt for direct/group dialog. /// Uses timestamp dedup (not time-based throttle) — only sends if latest incoming /// message timestamp > last sent read receipt timestamp. Retries once after 2s. + /// NOTE: No connectionState guard — sendPacket() auto-enqueues if not authenticated, + /// flushPacketQueue() sends on next handshake. Silent return was root cause of + /// read receipts lost after airplane-mode reconnect (UI updated but server never notified). func sendReadReceipt(toPublicKey: String) { let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = DatabaseManager.isGroupDialogKey(base) ? Self.normalizedGroupDialogIdentity(base) : base - let connState = ProtocolManager.shared.connectionState + let shortKey = String(normalized.prefix(8)) guard normalized != currentPublicKey, !normalized.isEmpty, !SystemAccounts.isSystemAccount(normalized), - let hash = privateKeyHash, - connState == .authenticated + let hash = privateKeyHash else { + Self.logger.debug("📖 ReadReceipt skip — guard fail for \(shortKey) (self=\(normalized == self.currentPublicKey), empty=\(normalized.isEmpty), hash=\(self.privateKeyHash != nil))") return } + let connState = ProtocolManager.shared.connectionState // Android parity: timestamp dedup — only send if latest incoming message // timestamp is newer than what we already sent a receipt for. let latestTs = MessageRepository.shared.latestIncomingTimestamp( for: normalized, myPublicKey: currentPublicKey ) ?? 0 let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 0 - if latestTs > 0, latestTs <= lastSentTs { return } + if latestTs > 0, latestTs <= lastSentTs { + Self.logger.debug("📖 ReadReceipt skip — dedup for \(shortKey) (latest=\(latestTs), lastSent=\(lastSentTs))") + return + } + + Self.logger.info("📖 ReadReceipt → \(shortKey) (ts=\(latestTs), conn=\(String(describing: connState)))") var packet = PacketRead() packet.privateKey = hash @@ -1302,8 +1311,8 @@ final class SessionManager { Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(2)) guard let self, - ProtocolManager.shared.connectionState == .authenticated, let hash = self.privateKeyHash else { return } + Self.logger.debug("📖 ReadReceipt retry → \(shortKey) (conn=\(String(describing: ProtocolManager.shared.connectionState)))") var retryPacket = PacketRead() retryPacket.privateKey = hash retryPacket.fromPublicKey = self.currentPublicKey @@ -1609,6 +1618,29 @@ final class SessionManager { self.requestedUserInfoKeys.removeAll() self.onlineSubscribedKeys.removeAll() + // Reset read receipt dedup — previous sends may have been lost if the + // TCP connection was silently broken. Without this, timestamp dedup + // blocks re-sending for dialogs where the receipt was "sent" but never + // actually reached the server (airplane mode → reconnect scenario). + let prevDedup = self.lastReadReceiptTimestamp.count + self.lastReadReceiptTimestamp.removeAll() + if prevDedup > 0 { + Self.logger.info("📖 Cleared \(prevDedup) read receipt dedup entries on reconnect") + } + + // Re-send read receipts for dialogs currently open and read-eligible. + // Handles the case where user opened a chat during reconnect, local UI + // updated (markIncomingAsRead), but PacketRead was enqueued/lost. + let eligibleKeys = MessageRepository.shared.activeDialogKeys.filter { + MessageRepository.shared.isDialogReadEligible($0) + } + if !eligibleKeys.isEmpty { + Self.logger.info("📖 Re-sending read receipts for \(eligibleKeys.count) active dialog(s) after reconnect") + } + for dialogKey in eligibleKeys { + self.sendReadReceipt(toPublicKey: dialogKey) + } + // Send push tokens to server for push notifications (Android parity). self.sendPushTokenToServer() self.sendVoIPTokenToServer() @@ -2948,6 +2980,11 @@ final class SessionManager { if message.toPublicKey == currentPublicKey { continue } + // Skip messages that were cancelled by the user + if cancelledOutgoingIds.remove(message.id) != nil { + MessageRepository.shared.deleteMessage(id: message.id) + continue + } // Group messages don't get server ACK — skip retry, mark delivered. if DatabaseManager.isGroupDialogKey(message.toPublicKey) { MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index 5364a44..310a789 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -980,11 +980,16 @@ final class ChatDetailViewController: UIViewController { pendingForwardData = forwardData syncComposerForwardState() } else { - // Different chat — pop and push target chat with forward data - let nav = navigationController - nav?.popViewController(animated: false) + // Different chat — replace self in nav stack (no chat list flash) + guard let nav = navigationController else { return } let detail = ChatDetailViewController(route: targetRoute, pendingForwardData: forwardData) - nav?.pushViewController(detail, animated: true) + var viewControllers = nav.viewControllers + if let idx = viewControllers.lastIndex(where: { $0 === self }) { + viewControllers[idx] = detail + } else { + viewControllers.append(detail) + } + nav.setViewControllers(viewControllers, animated: false) } } @@ -1397,8 +1402,17 @@ final class ChatDetailViewController: UIViewController { Task { @MainActor in do { if let fwdData = forwardData, !fwdData.isEmpty { + // Telegram parity: text and forward are separate messages + if !trimmed.isEmpty { + try await SessionManager.shared.sendMessage( + text: trimmed, + toPublicKey: route.publicKey, + opponentTitle: route.title, + opponentUsername: route.username + ) + } try await SessionManager.shared.sendMessageWithReply( - text: trimmed, + text: "", replyMessages: fwdData, toPublicKey: route.publicKey, opponentTitle: route.title, diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 0116f71..1bc4f54 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -861,13 +861,28 @@ final class NativeMessageCell: UICollectionViewCell { let initial = String(forwardSenderName.prefix(1)).uppercased() forwardAvatarInitialLabel.text = initial let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "") - let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5, 0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2] - let hex = hexes[colorIndex % hexes.count] - forwardAvatarView.backgroundColor = UIColor( - red: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: 1 - ) + // Mantine "light" variant: base + tint at low opacity (AvatarView parity) + let tint = RosettaColors.avatarColor(for: colorIndex) + forwardAvatarView.backgroundColor = UIColor { traits in + let isDark = traits.userInterfaceStyle == .dark + let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark + ? (0x1A / 255.0, 0x1B / 255.0, 0x1E / 255.0) + : (1.0, 1.0, 1.0) + let a: CGFloat = isDark ? 0.15 : 0.10 + var tR: CGFloat = 0, tG: CGFloat = 0, tB: CGFloat = 0, tA: CGFloat = 0 + tint.getRed(&tR, green: &tG, blue: &tB, alpha: &tA) + return UIColor( + red: base.r * (1 - a) + tR * a, + green: base.g * (1 - a) + tG * a, + blue: base.b * (1 - a) + tB * a, + alpha: 1 + ) + } + forwardAvatarInitialLabel.textColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? RosettaColors.avatarTextColor(for: colorIndex) + : RosettaColors.avatarColor(for: colorIndex) + } } } else { forwardLabel.isHidden = true @@ -2800,9 +2815,18 @@ final class NativeMessageCell: UICollectionViewCell { } layoutPhotoOverflowOverlay() let hasActiveUpload = TransportManager.uploadProgress[message.id] != nil - updatePhotoUploadingOverlay( - isVisible: layout.isOutgoing && (message.deliveryStatus == .waiting || hasActiveUpload) - ) + let shouldShow = layout.isOutgoing && (message.deliveryStatus == .waiting || hasActiveUpload) + if shouldShow { + updatePhotoUploadingOverlay(isVisible: true) + if uploadOverlayShowTime == 0 { uploadOverlayShowTime = CACurrentMediaTime() } + } else { + // Don't hide overlay during fast reconfigure — keep visible for at least 2s + let elapsed = CACurrentMediaTime() - uploadOverlayShowTime + if elapsed > 2.0 || uploadOverlayShowTime == 0 { + updatePhotoUploadingOverlay(isVisible: false) + uploadOverlayShowTime = 0 + } + } } /// Downloads forwarded images from CDN using forwardChachaKeyPlain for decryption. @@ -3254,6 +3278,7 @@ final class NativeMessageCell: UICollectionViewCell { } private var uploadProgressObserver: NSObjectProtocol? + private var uploadOverlayShowTime: TimeInterval = 0 private func updatePhotoUploadingOverlay(isVisible: Bool) { photoUploadingOverlayView.isHidden = !isVisible @@ -3262,6 +3287,7 @@ final class NativeMessageCell: UICollectionViewCell { // Cancel upload: prevent WebSocket send + delete the waiting message photoUploadingRing.onCancel = { [weak self] in guard let self, let msgId = self.message?.id else { return } + self.uploadOverlayShowTime = 0 SessionManager.shared.cancelOutgoingSend(messageId: msgId) MessageRepository.shared.deleteMessage(id: msgId) if let opponentKey = self.message?.toPublicKey { @@ -3788,6 +3814,7 @@ final class NativeMessageCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() + uploadOverlayShowTime = 0 if let observer = uploadProgressObserver { NotificationCenter.default.removeObserver(observer) uploadProgressObserver = nil @@ -4183,9 +4210,7 @@ final class ForwardItemSubview: UIView { textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption textLabel.isHidden = cleanCaption.isEmpty - // Avatar: real photo if available, otherwise initial + color - let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5, - 0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2] + // Avatar: real photo if available, otherwise Mantine "light" variant if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) { avatarImageView.image = avatarImage avatarImageView.isHidden = false @@ -4197,12 +4222,27 @@ final class ForwardItemSubview: UIView { avatarInitialLabel.isHidden = false avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased() let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey) - let hex = hexes[colorIndex % hexes.count] - avatarView.backgroundColor = UIColor( - red: CGFloat((hex >> 16) & 0xFF) / 255, - green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: 1 - ) + let tint = RosettaColors.avatarColor(for: colorIndex) + avatarView.backgroundColor = UIColor { traits in + let isDark = traits.userInterfaceStyle == .dark + let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark + ? (0x1A / 255.0, 0x1B / 255.0, 0x1E / 255.0) + : (1.0, 1.0, 1.0) + let a: CGFloat = isDark ? 0.15 : 0.10 + var tR: CGFloat = 0, tG: CGFloat = 0, tB: CGFloat = 0, tA: CGFloat = 0 + tint.getRed(&tR, green: &tG, blue: &tB, alpha: &tA) + return UIColor( + red: base.r * (1 - a) + tR * a, + green: base.g * (1 - a) + tG * a, + blue: base.b * (1 - a) + tB * a, + alpha: 1 + ) + } + avatarInitialLabel.textColor = UIColor { traits in + traits.userInterfaceStyle == .dark + ? RosettaColors.avatarTextColor(for: colorIndex) + : RosettaColors.avatarColor(for: colorIndex) + } } }