Форвард: preview bar в composer + навигация в целевой чат + Mantine аватарки

This commit is contained in:
2026-04-17 07:08:04 +05:00
parent 449b96f6fb
commit 88126c8673
3 changed files with 120 additions and 29 deletions

View File

@@ -1268,28 +1268,37 @@ final class SessionManager {
/// Android parity: sends read receipt for direct/group dialog. /// Android parity: sends read receipt for direct/group dialog.
/// Uses timestamp dedup (not time-based throttle) only sends if latest incoming /// Uses timestamp dedup (not time-based throttle) only sends if latest incoming
/// message timestamp > last sent read receipt timestamp. Retries once after 2s. /// 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) { func sendReadReceipt(toPublicKey: String) {
let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = DatabaseManager.isGroupDialogKey(base) let normalized = DatabaseManager.isGroupDialogKey(base)
? Self.normalizedGroupDialogIdentity(base) ? Self.normalizedGroupDialogIdentity(base)
: base : base
let connState = ProtocolManager.shared.connectionState let shortKey = String(normalized.prefix(8))
guard normalized != currentPublicKey, guard normalized != currentPublicKey,
!normalized.isEmpty, !normalized.isEmpty,
!SystemAccounts.isSystemAccount(normalized), !SystemAccounts.isSystemAccount(normalized),
let hash = privateKeyHash, let hash = privateKeyHash
connState == .authenticated
else { else {
Self.logger.debug("📖 ReadReceipt skip — guard fail for \(shortKey) (self=\(normalized == self.currentPublicKey), empty=\(normalized.isEmpty), hash=\(self.privateKeyHash != nil))")
return return
} }
let connState = ProtocolManager.shared.connectionState
// Android parity: timestamp dedup only send if latest incoming message // Android parity: timestamp dedup only send if latest incoming message
// timestamp is newer than what we already sent a receipt for. // timestamp is newer than what we already sent a receipt for.
let latestTs = MessageRepository.shared.latestIncomingTimestamp( let latestTs = MessageRepository.shared.latestIncomingTimestamp(
for: normalized, myPublicKey: currentPublicKey for: normalized, myPublicKey: currentPublicKey
) ?? 0 ) ?? 0
let lastSentTs = lastReadReceiptTimestamp[normalized] ?? 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() var packet = PacketRead()
packet.privateKey = hash packet.privateKey = hash
@@ -1302,8 +1311,8 @@ final class SessionManager {
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
guard let self, guard let self,
ProtocolManager.shared.connectionState == .authenticated,
let hash = self.privateKeyHash else { return } let hash = self.privateKeyHash else { return }
Self.logger.debug("📖 ReadReceipt retry → \(shortKey) (conn=\(String(describing: ProtocolManager.shared.connectionState)))")
var retryPacket = PacketRead() var retryPacket = PacketRead()
retryPacket.privateKey = hash retryPacket.privateKey = hash
retryPacket.fromPublicKey = self.currentPublicKey retryPacket.fromPublicKey = self.currentPublicKey
@@ -1609,6 +1618,29 @@ final class SessionManager {
self.requestedUserInfoKeys.removeAll() self.requestedUserInfoKeys.removeAll()
self.onlineSubscribedKeys.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). // Send push tokens to server for push notifications (Android parity).
self.sendPushTokenToServer() self.sendPushTokenToServer()
self.sendVoIPTokenToServer() self.sendVoIPTokenToServer()
@@ -2948,6 +2980,11 @@ final class SessionManager {
if message.toPublicKey == currentPublicKey { if message.toPublicKey == currentPublicKey {
continue 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. // Group messages don't get server ACK skip retry, mark delivered.
if DatabaseManager.isGroupDialogKey(message.toPublicKey) { if DatabaseManager.isGroupDialogKey(message.toPublicKey) {
MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered) MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered)

View File

@@ -980,11 +980,16 @@ final class ChatDetailViewController: UIViewController {
pendingForwardData = forwardData pendingForwardData = forwardData
syncComposerForwardState() syncComposerForwardState()
} else { } else {
// Different chat pop and push target chat with forward data // Different chat replace self in nav stack (no chat list flash)
let nav = navigationController guard let nav = navigationController else { return }
nav?.popViewController(animated: false)
let detail = ChatDetailViewController(route: targetRoute, pendingForwardData: forwardData) 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 Task { @MainActor in
do { do {
if let fwdData = forwardData, !fwdData.isEmpty { 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( try await SessionManager.shared.sendMessageWithReply(
text: trimmed, text: "",
replyMessages: fwdData, replyMessages: fwdData,
toPublicKey: route.publicKey, toPublicKey: route.publicKey,
opponentTitle: route.title, opponentTitle: route.title,

View File

@@ -861,13 +861,28 @@ final class NativeMessageCell: UICollectionViewCell {
let initial = String(forwardSenderName.prefix(1)).uppercased() let initial = String(forwardSenderName.prefix(1)).uppercased()
forwardAvatarInitialLabel.text = initial forwardAvatarInitialLabel.text = initial
let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "") let colorIndex = RosettaColors.avatarColorIndex(for: forwardSenderName, publicKey: forwardSenderKey ?? "")
let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5, 0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2] // Mantine "light" variant: base + tint at low opacity (AvatarView parity)
let hex = hexes[colorIndex % hexes.count] let tint = RosettaColors.avatarColor(for: colorIndex)
forwardAvatarView.backgroundColor = UIColor( forwardAvatarView.backgroundColor = UIColor { traits in
red: CGFloat((hex >> 16) & 0xFF) / 255, let isDark = traits.userInterfaceStyle == .dark
green: CGFloat((hex >> 8) & 0xFF) / 255, let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark
blue: CGFloat(hex & 0xFF) / 255, alpha: 1 ? (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 { } else {
forwardLabel.isHidden = true forwardLabel.isHidden = true
@@ -2800,9 +2815,18 @@ final class NativeMessageCell: UICollectionViewCell {
} }
layoutPhotoOverflowOverlay() layoutPhotoOverflowOverlay()
let hasActiveUpload = TransportManager.uploadProgress[message.id] != nil let hasActiveUpload = TransportManager.uploadProgress[message.id] != nil
updatePhotoUploadingOverlay( let shouldShow = layout.isOutgoing && (message.deliveryStatus == .waiting || hasActiveUpload)
isVisible: 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. /// Downloads forwarded images from CDN using forwardChachaKeyPlain for decryption.
@@ -3254,6 +3278,7 @@ final class NativeMessageCell: UICollectionViewCell {
} }
private var uploadProgressObserver: NSObjectProtocol? private var uploadProgressObserver: NSObjectProtocol?
private var uploadOverlayShowTime: TimeInterval = 0
private func updatePhotoUploadingOverlay(isVisible: Bool) { private func updatePhotoUploadingOverlay(isVisible: Bool) {
photoUploadingOverlayView.isHidden = !isVisible photoUploadingOverlayView.isHidden = !isVisible
@@ -3262,6 +3287,7 @@ final class NativeMessageCell: UICollectionViewCell {
// Cancel upload: prevent WebSocket send + delete the waiting message // Cancel upload: prevent WebSocket send + delete the waiting message
photoUploadingRing.onCancel = { [weak self] in photoUploadingRing.onCancel = { [weak self] in
guard let self, let msgId = self.message?.id else { return } guard let self, let msgId = self.message?.id else { return }
self.uploadOverlayShowTime = 0
SessionManager.shared.cancelOutgoingSend(messageId: msgId) SessionManager.shared.cancelOutgoingSend(messageId: msgId)
MessageRepository.shared.deleteMessage(id: msgId) MessageRepository.shared.deleteMessage(id: msgId)
if let opponentKey = self.message?.toPublicKey { if let opponentKey = self.message?.toPublicKey {
@@ -3788,6 +3814,7 @@ final class NativeMessageCell: UICollectionViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
uploadOverlayShowTime = 0
if let observer = uploadProgressObserver { if let observer = uploadProgressObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
uploadProgressObserver = nil uploadProgressObserver = nil
@@ -4183,9 +4210,7 @@ final class ForwardItemSubview: UIView {
textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption textLabel.text = cleanCaption.isEmpty ? nil : cleanCaption
textLabel.isHidden = cleanCaption.isEmpty textLabel.isHidden = cleanCaption.isEmpty
// Avatar: real photo if available, otherwise initial + color // Avatar: real photo if available, otherwise Mantine "light" variant
let hexes: [UInt32] = [0x228be6, 0x15aabf, 0xbe4bdb, 0x40c057, 0x4c6ef5,
0x82c91e, 0xfd7e14, 0xe64980, 0xfa5252, 0x12b886, 0x7950f2]
if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) { if let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: info.senderKey) {
avatarImageView.image = avatarImage avatarImageView.image = avatarImage
avatarImageView.isHidden = false avatarImageView.isHidden = false
@@ -4197,12 +4222,27 @@ final class ForwardItemSubview: UIView {
avatarInitialLabel.isHidden = false avatarInitialLabel.isHidden = false
avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased() avatarInitialLabel.text = String(info.senderName.prefix(1)).uppercased()
let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey) let colorIndex = RosettaColors.avatarColorIndex(for: info.senderName, publicKey: info.senderKey)
let hex = hexes[colorIndex % hexes.count] let tint = RosettaColors.avatarColor(for: colorIndex)
avatarView.backgroundColor = UIColor( avatarView.backgroundColor = UIColor { traits in
red: CGFloat((hex >> 16) & 0xFF) / 255, let isDark = traits.userInterfaceStyle == .dark
green: CGFloat((hex >> 8) & 0xFF) / 255, let base: (r: CGFloat, g: CGFloat, b: CGFloat) = isDark
blue: CGFloat(hex & 0xFF) / 255, alpha: 1 ? (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)
}
} }
} }