Форвард: preview bar в composer + навигация в целевой чат + Mantine аватарки
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user