Форвард: 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.
|
/// 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user