Форвард: 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.
/// 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)

View File

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

View File

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