Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы
This commit is contained in:
@@ -504,6 +504,12 @@ final class DialogRepository {
|
||||
// CHNK: chunked format
|
||||
if trimmed.hasPrefix("CHNK:") { return true }
|
||||
|
||||
// Pure hex string (≥40 chars) — XChaCha20 wire format
|
||||
if trimmed.count >= 40 {
|
||||
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
|
||||
}
|
||||
|
||||
// Original check: all characters are garbage (U+FFFD, control chars, null bytes)
|
||||
let validCharacters = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.value != 0xFFFD &&
|
||||
|
||||
@@ -408,7 +408,37 @@ final class MessageRepository: ObservableObject {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[DB] upsert error: \(error)")
|
||||
// Retry once — transient GRDB errors (SQLITE_BUSY, lock contention)
|
||||
// should not permanently lose a message.
|
||||
print("[DB] upsert error (will retry): \(error)")
|
||||
do {
|
||||
try db.writeSync { db in
|
||||
var record = MessageRecord(
|
||||
id: nil,
|
||||
account: myPublicKey,
|
||||
fromPublicKey: packet.fromPublicKey,
|
||||
toPublicKey: packet.toPublicKey,
|
||||
content: packet.content,
|
||||
chachaKey: packet.chachaKey,
|
||||
text: storedText,
|
||||
plainMessage: storedText,
|
||||
timestamp: timestamp,
|
||||
isRead: incomingRead ? 1 : 0,
|
||||
readAlias: incomingRead ? 1 : 0,
|
||||
fromMe: fromMe ? 1 : 0,
|
||||
deliveryStatus: outgoingStatus.rawValue,
|
||||
deliveredAlias: outgoingStatus.rawValue,
|
||||
messageId: messageId,
|
||||
replyToMessageId: nil,
|
||||
dialogKey: dialogKey,
|
||||
attachments: attachmentsJSON,
|
||||
attachmentPassword: attachmentPassword
|
||||
)
|
||||
try record.insert(db, onConflict: .replace)
|
||||
}
|
||||
} catch {
|
||||
print("[DB] upsert retry FAILED — message may be lost: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced cache refresh — batch during sync
|
||||
@@ -869,7 +899,21 @@ final class MessageRepository: ObservableObject {
|
||||
plainText = decrypted
|
||||
} else {
|
||||
let fallback = Self.safePlainMessageFallback(record.text)
|
||||
plainText = fallback
|
||||
|
||||
// Desktop/Android parity: lazy re-decrypt from encrypted wire content.
|
||||
// When a message was stored with decrypt failure (empty text but content
|
||||
// and chachaKey present), try ECDH/group decryption with current keys.
|
||||
if fallback.isEmpty, !record.content.isEmpty {
|
||||
if let retried = Self.tryReDecrypt(record: record, privateKeyHex: privateKey) {
|
||||
plainText = retried
|
||||
// Persist successful re-decrypt to DB so we don't retry every load
|
||||
persistReDecryptedText(retried, forMessageId: record.messageId, privateKey: privateKey)
|
||||
} else {
|
||||
plainText = fallback
|
||||
}
|
||||
} else {
|
||||
plainText = fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
plainText = Self.safePlainMessageFallback(record.text)
|
||||
@@ -878,6 +922,68 @@ final class MessageRepository: ObservableObject {
|
||||
return record.toChatMessage(overrideText: plainText)
|
||||
}
|
||||
|
||||
// MARK: - Lazy Re-Decryption (Desktop/Android Parity)
|
||||
|
||||
/// Attempts to decrypt a message from its stored encrypted wire content.
|
||||
/// Tries ECDH path (direct messages) and group key path (group messages).
|
||||
/// Called when primary decryption (from `text` column) fails but raw content exists.
|
||||
private static func tryReDecrypt(record: MessageRecord, privateKeyHex: String) -> String? {
|
||||
guard !record.content.isEmpty else { return nil }
|
||||
|
||||
// Path 1: ECDH path — chachaKey + privateKey → XChaCha20 decrypt
|
||||
if !record.chachaKey.isEmpty {
|
||||
do {
|
||||
let (text, _) = try MessageCrypto.decryptIncomingFull(
|
||||
ciphertext: record.content,
|
||||
encryptedKey: record.chachaKey,
|
||||
myPrivateKeyHex: privateKeyHex
|
||||
)
|
||||
return text
|
||||
} catch {
|
||||
// Fall through to group path
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: Group messages — try group key if dialog is a group.
|
||||
// Group dialogKeys are stored as-is (e.g., "#group:xxx"), not "account:opponent".
|
||||
if DatabaseManager.isGroupDialogKey(record.dialogKey) {
|
||||
if let groupKey = GroupRepository.shared.groupKey(
|
||||
account: record.account,
|
||||
privateKeyHex: privateKeyHex,
|
||||
groupDialogKey: record.dialogKey
|
||||
) {
|
||||
if let data = try? CryptoManager.shared.decryptWithPassword(
|
||||
record.content, password: groupKey
|
||||
), let text = String(data: data, encoding: .utf8) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Persists successfully re-decrypted text back to DB.
|
||||
private func persistReDecryptedText(_ text: String, forMessageId messageId: String, privateKey: String) {
|
||||
guard !currentAccount.isEmpty else { return }
|
||||
let storedText: String
|
||||
if let enc = try? CryptoManager.shared.encryptWithPassword(Data(text.utf8), password: privateKey) {
|
||||
storedText = enc
|
||||
} else {
|
||||
storedText = text
|
||||
}
|
||||
do {
|
||||
try db.writeSync { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE messages SET text = ?, plain_message = ? WHERE account = ? AND message_id = ?",
|
||||
arguments: [storedText, storedText, currentAccount, messageId]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — message will retry on next load
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Android Parity: safePlainMessageFallback
|
||||
|
||||
/// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349.
|
||||
@@ -888,19 +994,28 @@ final class MessageRepository: ObservableObject {
|
||||
return raw // Looks like real plaintext (legacy unencrypted record)
|
||||
}
|
||||
|
||||
/// Android parity: `isProbablyEncryptedPayload()` — detects `ivBase64:ctBase64` format.
|
||||
/// Android parity: `isProbablyEncryptedPayload()` — detects encrypted formats.
|
||||
private static func isProbablyEncryptedPayload(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// Chunked format
|
||||
if trimmed.hasPrefix("CHNK:") { return true }
|
||||
// ivBase64:ctBase64 format
|
||||
// ivBase64:ctBase64 format — both parts must be ≥16 chars to avoid
|
||||
// false positives on short strings like "hello:world".
|
||||
// Aligned with MessageCellLayout.isGarbageOrEncrypted() and
|
||||
// DialogRepository.isGarbageText().
|
||||
let parts = trimmed.components(separatedBy: ":")
|
||||
guard parts.count == 2 else { return false }
|
||||
// Both parts should look like Base64 (alphanumeric + /+=)
|
||||
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
|
||||
return parts.allSatisfy { part in
|
||||
!part.isEmpty && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
|
||||
if parts.count == 2 {
|
||||
let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/="))
|
||||
if parts.allSatisfy({ part in
|
||||
part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
|
||||
}) { return true }
|
||||
}
|
||||
// Pure hex string (≥40 chars) — XChaCha20 wire format
|
||||
if trimmed.count >= 40 {
|
||||
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func normalizeTimestamp(_ raw: Int64) -> Int64 {
|
||||
|
||||
@@ -175,6 +175,7 @@ extension MessageCellLayout {
|
||||
messageType = .text
|
||||
}
|
||||
let isTextMessage = (messageType == .text || messageType == .textWithReply)
|
||||
let isForwardWithCaption = messageType == .forward && !config.text.isEmpty
|
||||
let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
|
||||
fontPointSize: font.pointSize,
|
||||
screenPixel: screenPixel
|
||||
@@ -198,7 +199,7 @@ extension MessageCellLayout {
|
||||
// Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation)
|
||||
// Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation
|
||||
let statusTrailingCompensation: CGFloat
|
||||
if isTextMessage && config.isOutgoing {
|
||||
if (isTextMessage || isForwardWithCaption) && config.isOutgoing {
|
||||
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||
} else {
|
||||
statusTrailingCompensation = 0
|
||||
@@ -210,7 +211,7 @@ extension MessageCellLayout {
|
||||
|
||||
let textMeasurement: TextMeasurement
|
||||
var cachedTextLayout: CoreTextTextLayout?
|
||||
let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption
|
||||
let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption || (messageType == .forward && !config.text.isEmpty)
|
||||
if !config.text.isEmpty && needsDetailedTextLayout {
|
||||
// CoreText (CTTypesetter) — returns per-line widths including lastLineWidth.
|
||||
// Also captures CoreTextTextLayout for cell rendering (avoids double computation).
|
||||
@@ -239,7 +240,7 @@ extension MessageCellLayout {
|
||||
let metadataWidth = tsSize.width + timeToCheckGap + checkW
|
||||
|
||||
let trailingWidthForStatus: CGFloat
|
||||
if isTextMessage && !config.text.isEmpty {
|
||||
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
|
||||
if let cachedTextLayout {
|
||||
if cachedTextLayout.lastLineHasRTL {
|
||||
trailingWidthForStatus = 10_000
|
||||
@@ -277,7 +278,7 @@ extension MessageCellLayout {
|
||||
|
||||
// ── STEP 3: Inline vs Wrapped determination ──
|
||||
let timestampInline: Bool
|
||||
if isTextMessage && !config.text.isEmpty {
|
||||
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
|
||||
timestampInline = inlineStatusContentWidth <= maxTextWidth
|
||||
} else {
|
||||
timestampInline = true
|
||||
@@ -290,10 +291,10 @@ extension MessageCellLayout {
|
||||
// Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container
|
||||
let replyContainerH: CGFloat = 42
|
||||
let replyTopInset: CGFloat = 5
|
||||
let replyBottomGap: CGFloat = 7 // Telegram: 7pt from reply container bottom to message text
|
||||
let replyBottomGap: CGFloat = 3
|
||||
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
|
||||
var photoH: CGFloat = 0
|
||||
let forwardHeaderH: CGFloat = config.isForward ? 40 : 0
|
||||
let forwardHeaderH: CGFloat = config.isForward ? 41 : 0
|
||||
var fileH: CGFloat = CGFloat(config.fileCount) * 52
|
||||
+ CGFloat(config.callCount) * 42
|
||||
+ CGFloat(config.avatarCount) * 52
|
||||
@@ -378,12 +379,25 @@ extension MessageCellLayout {
|
||||
bubbleW = leftPad + finalContentW + rightPad
|
||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
||||
if config.hasReplyQuote { bubbleW = max(bubbleW, 180) }
|
||||
} else if isForwardWithCaption {
|
||||
// Forward with caption — SAME sizing as text messages (Telegram parity)
|
||||
// 2pt gap from forward header to text (Telegram: spacing after forward = 2pt)
|
||||
let actualTextW = textMeasurement.size.width
|
||||
let finalContentW: CGFloat
|
||||
if timestampInline {
|
||||
finalContentW = max(actualTextW, inlineStatusContentWidth)
|
||||
bubbleH += 2 + textMeasurement.size.height + bottomPad
|
||||
} else {
|
||||
finalContentW = max(actualTextW, wrappedStatusContentWidth)
|
||||
bubbleH += 2 + textMeasurement.size.height + 15 + bottomPad
|
||||
}
|
||||
bubbleW = leftPad + finalContentW + rightPad
|
||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
||||
} else if !config.text.isEmpty {
|
||||
// Non-text with caption (file, forward)
|
||||
// Non-text with caption (file)
|
||||
let finalContentW = max(textMeasurement.size.width, metadataWidth)
|
||||
bubbleW = leftPad + finalContentW + rightPad
|
||||
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
|
||||
// File/call/avatar with text: ensure min width for icon+text layout
|
||||
let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220
|
||||
if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) }
|
||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||
@@ -412,9 +426,24 @@ extension MessageCellLayout {
|
||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
|
||||
bubbleH = max(bubbleH, 37)
|
||||
}
|
||||
// Forward header needs minimum width for "Forwarded from" + avatar + name
|
||||
if config.isForward {
|
||||
bubbleW = max(bubbleW, min(200, effectiveMaxBubbleWidth))
|
||||
}
|
||||
// Stretchable bubble image min height
|
||||
bubbleH = max(bubbleH, 37)
|
||||
|
||||
// Forward/reply text area must match regular text bubble min-height behavior.
|
||||
// Without this, text and timestamp share the same bottom edge (look "centered").
|
||||
// With this, the text area gets the same ~5pt extra padding as regular text bubbles.
|
||||
if (isForwardWithCaption || (config.hasReplyQuote && !config.text.isEmpty)) {
|
||||
let headerH = forwardHeaderH + replyH
|
||||
let textAreaH = bubbleH - headerH
|
||||
if textAreaH < 37 {
|
||||
bubbleH = headerH + 37
|
||||
}
|
||||
}
|
||||
|
||||
// Date header adds height above the bubble.
|
||||
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
|
||||
|
||||
@@ -437,7 +466,7 @@ extension MessageCellLayout {
|
||||
// Photo right = bubbleW - 2. Gap = 6pt → pill right = bubbleW - 8.
|
||||
// → statusEndX = bubbleW - 15 → metadataRightInset = 15.
|
||||
metadataRightInset = 15
|
||||
} else if isTextMessage || messageType == .photoWithCaption {
|
||||
} else if isTextMessage || isForwardWithCaption || messageType == .photoWithCaption {
|
||||
metadataRightInset = config.isOutgoing
|
||||
? textStatusLaneMetrics.textStatusRightInset
|
||||
: rightPad
|
||||
@@ -457,7 +486,7 @@ extension MessageCellLayout {
|
||||
}
|
||||
let statusEndX = bubbleW - metadataRightInset
|
||||
let statusEndY = bubbleH - metadataBottomInset
|
||||
let statusVerticalOffset: CGFloat = isTextMessage
|
||||
let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption)
|
||||
? textStatusLaneMetrics.verticalOffset
|
||||
: 0
|
||||
|
||||
@@ -484,7 +513,7 @@ extension MessageCellLayout {
|
||||
if hasStatusIcon {
|
||||
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
|
||||
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
|
||||
let checkOffset: CGFloat = isTextMessage
|
||||
let checkOffset: CGFloat = (isTextMessage || isForwardWithCaption)
|
||||
? textStatusLaneMetrics.checkOffset
|
||||
: floor(font.pointSize * 6.0 / 17.0)
|
||||
let checkReadX = statusEndX - checkImgW
|
||||
@@ -510,7 +539,7 @@ extension MessageCellLayout {
|
||||
// line breaks ("jagged first line"). The content area is always ≥ measured width.
|
||||
var textY: CGFloat = topPad
|
||||
if config.hasReplyQuote { textY = replyH + topPad }
|
||||
if forwardHeaderH > 0 { textY = forwardHeaderH + topPad }
|
||||
if forwardHeaderH > 0 { textY = forwardHeaderH + 2 }
|
||||
if photoH > 0 {
|
||||
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
|
||||
textY = photoH + 4 + 6 + topPad
|
||||
@@ -544,9 +573,9 @@ extension MessageCellLayout {
|
||||
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
|
||||
let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH)
|
||||
|
||||
let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14)
|
||||
let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20)
|
||||
let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17)
|
||||
let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17)
|
||||
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16)
|
||||
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17)
|
||||
|
||||
let layout = MessageCellLayout(
|
||||
totalHeight: totalH,
|
||||
@@ -677,6 +706,9 @@ extension MessageCellLayout {
|
||||
}
|
||||
if validChars.isEmpty { return true }
|
||||
|
||||
// Chunked format
|
||||
if trimmed.hasPrefix("CHNK:") { return true }
|
||||
|
||||
// Check for encrypted ciphertext: Base64:Base64 pattern
|
||||
let parts = trimmed.components(separatedBy: ":")
|
||||
if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 {
|
||||
@@ -688,6 +720,12 @@ extension MessageCellLayout {
|
||||
if allBase64 { return true }
|
||||
}
|
||||
|
||||
// Pure hex string (≥40 chars) — XChaCha20 wire format
|
||||
if trimmed.count >= 40 {
|
||||
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
||||
if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -867,6 +905,23 @@ extension MessageCellLayout {
|
||||
let hasReply = message.attachments.contains { $0.type == .messages }
|
||||
let isForward = hasReply && displayText.isEmpty
|
||||
|
||||
// Parse forward content from .messages blob
|
||||
var forwardCaption: String?
|
||||
var forwardInnerImageCount = 0
|
||||
var forwardInnerFileCount = 0
|
||||
if isForward,
|
||||
let att = message.attachments.first(where: { $0.type == .messages }),
|
||||
let data = att.blob.data(using: .utf8),
|
||||
let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data),
|
||||
let first = replies.first {
|
||||
let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) {
|
||||
forwardCaption = fwdText
|
||||
}
|
||||
forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
|
||||
forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count
|
||||
}
|
||||
|
||||
// Parse image dimensions from preview field (format: "tag::blurhash::WxH")
|
||||
let imageDims: CGSize? = images.first.flatMap {
|
||||
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
|
||||
@@ -877,7 +932,7 @@ extension MessageCellLayout {
|
||||
isOutgoing: isOutgoing,
|
||||
position: position,
|
||||
deliveryStatus: message.deliveryStatus,
|
||||
text: displayText,
|
||||
text: isForward ? (forwardCaption ?? "") : displayText,
|
||||
timestampText: timestampText,
|
||||
hasReplyQuote: hasReply && !displayText.isEmpty,
|
||||
replyName: nil,
|
||||
@@ -888,9 +943,9 @@ extension MessageCellLayout {
|
||||
avatarCount: avatars.count,
|
||||
callCount: calls.count,
|
||||
isForward: isForward,
|
||||
forwardImageCount: isForward ? images.count : 0,
|
||||
forwardFileCount: isForward ? files.count : 0,
|
||||
forwardCaption: nil,
|
||||
forwardImageCount: forwardInnerImageCount,
|
||||
forwardFileCount: forwardInnerFileCount,
|
||||
forwardCaption: forwardCaption,
|
||||
showsDateHeader: showsDateHeader,
|
||||
dateHeaderText: dateHeaderText
|
||||
)
|
||||
|
||||
@@ -66,13 +66,21 @@ final class SessionManager {
|
||||
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
|
||||
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
|
||||
|
||||
// MARK: - Foreground Detection (Android parity)
|
||||
// MARK: - Foreground & Idle Detection (Desktop/Android parity)
|
||||
|
||||
private var foregroundObserverToken: NSObjectProtocol?
|
||||
private var backgroundObserverToken: NSObjectProtocol?
|
||||
/// Android parity: 5s debounce between foreground sync requests.
|
||||
private var lastForegroundSyncTime: TimeInterval = 0
|
||||
|
||||
/// Desktop/Android parity: 20s idle timeout clears read eligibility.
|
||||
/// Desktop: `useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000)` (20s).
|
||||
/// Android: `useIdle(20000)`.
|
||||
/// Reset by ChatDetailView on scroll/tap via `resetIdleTimer()`.
|
||||
private static let idleTimeoutSeconds: TimeInterval = 20
|
||||
private var idleTimer: Timer?
|
||||
private(set) var isUserIdle = false
|
||||
|
||||
/// Whether the app is in the foreground.
|
||||
private var isAppInForeground: Bool {
|
||||
UIApplication.shared.applicationState == .active
|
||||
@@ -121,7 +129,7 @@ final class SessionManager {
|
||||
}
|
||||
|
||||
/// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
|
||||
/// Called on foreground resume. Android has no idle detection — just re-marks on resume.
|
||||
/// Called on foreground resume. Safe after background clear: readEligibleDialogs is empty.
|
||||
func markActiveDialogsAsRead() {
|
||||
let activeKeys = MessageRepository.shared.activeDialogKeys
|
||||
let myKey = currentPublicKey
|
||||
@@ -136,6 +144,32 @@ final class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle Detection (Desktop/Android parity: 20s)
|
||||
|
||||
/// Reset the idle timer. Called by ChatDetailView on user interaction
|
||||
/// (scroll, tap, message send). Restores read eligibility if was idle.
|
||||
func resetIdleTimer() {
|
||||
idleTimer?.invalidate()
|
||||
if isUserIdle {
|
||||
isUserIdle = false
|
||||
// ChatDetailView will call updateReadEligibility() on its own
|
||||
}
|
||||
idleTimer = Timer.scheduledTimer(withTimeInterval: Self.idleTimeoutSeconds, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, !self.isUserIdle else { return }
|
||||
self.isUserIdle = true
|
||||
MessageRepository.shared.clearAllReadEligibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the idle timer (e.g., on session end or leaving chat).
|
||||
func stopIdleTimer() {
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
isUserIdle = false
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
/// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake.
|
||||
@@ -1488,7 +1522,19 @@ final class SessionManager {
|
||||
)
|
||||
}()
|
||||
if isGroupDialog, groupKey == nil {
|
||||
Self.logger.warning("processIncoming: group key not found for \(opponentKey)")
|
||||
// Don't drop the message — store with encrypted content for retry.
|
||||
// Group key may arrive later (join confirmation, sync).
|
||||
Self.logger.warning("processIncoming: group key not found for \(opponentKey) — storing fallback")
|
||||
let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive
|
||||
|| packet.fromPublicKey == myKey
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet,
|
||||
myPublicKey: myKey,
|
||||
decryptedText: "",
|
||||
fromSync: effectiveFromSync,
|
||||
dialogIdentityOverride: opponentKey
|
||||
)
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1506,9 +1552,27 @@ final class SessionManager {
|
||||
}.value
|
||||
|
||||
guard let cryptoResult else {
|
||||
Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))…")
|
||||
// Still recalculate dialog — cleans up stale ciphertext in lastMessage
|
||||
// that may persist from previous sessions or failed decryptions.
|
||||
// Desktop/Android parity: NEVER drop a message on decrypt failure.
|
||||
// Desktop and Android store encrypted content and retry decryption
|
||||
// on load. iOS was the only platform that lost messages permanently.
|
||||
Self.logger.warning("""
|
||||
processIncoming: decrypt FAILED — storing fallback \
|
||||
msgId=\(packet.messageId.prefix(8))… \
|
||||
from=\(packet.fromPublicKey.prefix(8))… \
|
||||
hasChachaKey=\(!packet.chachaKey.isEmpty) \
|
||||
hasAesChachaKey=\(!packet.aesChachaKey.isEmpty) \
|
||||
contentLen=\(packet.content.count) \
|
||||
isOwnMessage=\(fromMe)
|
||||
""")
|
||||
|
||||
let effectiveFromSync = syncBatchInProgress || ProtocolManager.shared.isSyncBatchActive || fromMe
|
||||
MessageRepository.shared.upsertFromMessagePacket(
|
||||
packet,
|
||||
myPublicKey: myKey,
|
||||
decryptedText: "",
|
||||
fromSync: effectiveFromSync,
|
||||
dialogIdentityOverride: opponentKey
|
||||
)
|
||||
DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey)
|
||||
return
|
||||
}
|
||||
@@ -1566,13 +1630,14 @@ final class SessionManager {
|
||||
// Sending 0x08 for every received message was causing a packet flood
|
||||
// that triggered server RST disconnects.
|
||||
|
||||
// Android parity: mark as read if dialog is active AND app is in foreground.
|
||||
// Android has NO idle detection — only isDialogActive flag (ON_RESUME/ON_PAUSE).
|
||||
// Desktop/Android parity: mark as read if dialog is active, read-eligible,
|
||||
// app is in foreground, AND user is not idle (20s timeout).
|
||||
let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey)
|
||||
let dialogIsReadEligible = MessageRepository.shared.isDialogReadEligible(opponentKey)
|
||||
let isSystem = SystemAccounts.isSystemAccount(opponentKey)
|
||||
let fg = isAppInForeground
|
||||
let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem
|
||||
let idle = isUserIdle
|
||||
let shouldMarkRead = dialogIsActive && dialogIsReadEligible && fg && !isSystem && !idle
|
||||
|
||||
if shouldMarkRead {
|
||||
DialogRepository.shared.markAsRead(opponentKey: opponentKey)
|
||||
|
||||
@@ -151,6 +151,12 @@ enum RosettaColors {
|
||||
return Int(index)
|
||||
}
|
||||
|
||||
/// Returns UIColor for a given avatar color index (for UIKit contexts).
|
||||
static func avatarColor(for index: Int) -> UIColor {
|
||||
let safeIndex = index % avatarColors.count
|
||||
return UIColor(avatarColors[safeIndex].tint)
|
||||
}
|
||||
|
||||
static func avatarText(publicKey: String) -> String {
|
||||
String(publicKey.prefix(2)).uppercased()
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ struct ChatDetailView: View {
|
||||
// setDialogActive only touches MessageRepository.activeDialogs (Set),
|
||||
// does NOT mutate DialogRepository, so ForEach won't rebuild.
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
updateReadEligibility()
|
||||
clearDeliveredNotifications(for: route.publicKey)
|
||||
// Telegram-like read policy: mark read only when dialog is truly readable
|
||||
@@ -275,6 +276,7 @@ struct ChatDetailView: View {
|
||||
isViewActive = false
|
||||
updateReadEligibility()
|
||||
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
|
||||
SessionManager.shared.stopIdleTimer()
|
||||
// Desktop parity: save draft text on chat close.
|
||||
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
|
||||
}
|
||||
@@ -285,6 +287,7 @@ struct ChatDetailView: View {
|
||||
// 600ms delay lets notification-tap navigation settle — if user tapped
|
||||
// a notification for a DIFFERENT chat, isViewActive becomes false.
|
||||
guard isViewActive else { return }
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
guard isViewActive else { return }
|
||||
@@ -838,6 +841,7 @@ private extension ChatDetailView {
|
||||
scrollToBottomRequested: $scrollToBottomRequested,
|
||||
onAtBottomChange: { atBottom in
|
||||
isAtBottom = atBottom
|
||||
SessionManager.shared.resetIdleTimer()
|
||||
updateReadEligibility()
|
||||
if atBottom {
|
||||
markDialogAsRead()
|
||||
|
||||
@@ -172,25 +172,25 @@ struct MessageCellView: View, Equatable {
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Forwarded from")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 6)
|
||||
.padding(.top, 7)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
AvatarView(
|
||||
initials: senderInitials,
|
||||
colorIndex: senderColorIndex,
|
||||
size: 20,
|
||||
size: 16,
|
||||
image: senderAvatar
|
||||
)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
.padding(.top, 0)
|
||||
|
||||
if !imageAttachments.isEmpty {
|
||||
ForwardedPhotoCollageView(
|
||||
|
||||
@@ -19,8 +19,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
|
||||
private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let forwardLabelFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
private static let fileNameFont = UIFont.systemFont(ofSize: 16, weight: .regular)
|
||||
private static let fileSizeFont = UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||||
private static let bubbleMetrics = BubbleMetrics.telegram()
|
||||
@@ -136,6 +136,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Forward header
|
||||
private let forwardLabel = UILabel()
|
||||
private let forwardAvatarView = UIView()
|
||||
private let forwardAvatarInitialLabel = UILabel()
|
||||
private let forwardAvatarImageView = UIImageView()
|
||||
private let forwardNameLabel = UILabel()
|
||||
|
||||
// Highlight overlay (scroll-to-message flash)
|
||||
@@ -397,16 +399,24 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
// Forward header
|
||||
forwardLabel.font = Self.forwardLabelFont
|
||||
forwardLabel.text = "Forwarded message"
|
||||
forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
forwardLabel.text = "Forwarded from"
|
||||
bubbleView.addSubview(forwardLabel)
|
||||
|
||||
forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
|
||||
forwardAvatarView.layer.cornerRadius = 10
|
||||
forwardAvatarView.layer.cornerRadius = 8
|
||||
forwardAvatarView.clipsToBounds = true
|
||||
bubbleView.addSubview(forwardAvatarView)
|
||||
|
||||
forwardAvatarInitialLabel.font = .systemFont(ofSize: 8, weight: .medium)
|
||||
forwardAvatarInitialLabel.textColor = .white
|
||||
forwardAvatarInitialLabel.textAlignment = .center
|
||||
forwardAvatarView.addSubview(forwardAvatarInitialLabel)
|
||||
|
||||
forwardAvatarImageView.contentMode = .scaleAspectFill
|
||||
forwardAvatarImageView.clipsToBounds = true
|
||||
forwardAvatarView.addSubview(forwardAvatarImageView)
|
||||
|
||||
forwardNameLabel.font = Self.forwardNameFont
|
||||
forwardNameLabel.textColor = .white
|
||||
forwardNameLabel.textColor = .white // default, overridden in configure()
|
||||
bubbleView.addSubview(forwardNameLabel)
|
||||
|
||||
// Highlight overlay — on top of all bubble content
|
||||
@@ -457,7 +467,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
replyName: String? = nil,
|
||||
replyText: String? = nil,
|
||||
replyMessageId: String? = nil,
|
||||
forwardSenderName: String? = nil
|
||||
forwardSenderName: String? = nil,
|
||||
forwardSenderKey: String? = nil
|
||||
) {
|
||||
self.message = message
|
||||
self.actions = actions
|
||||
@@ -542,6 +553,31 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
forwardAvatarView.isHidden = false
|
||||
forwardNameLabel.isHidden = false
|
||||
forwardNameLabel.text = forwardSenderName
|
||||
// Telegram: same accentTextColor for both title and name
|
||||
let accent: UIColor = isOutgoing ? .white : Self.outgoingColor
|
||||
forwardLabel.textColor = accent
|
||||
forwardNameLabel.textColor = accent
|
||||
// Avatar: real photo if available, otherwise initial + color
|
||||
if let key = forwardSenderKey, let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: key) {
|
||||
forwardAvatarImageView.image = avatarImage
|
||||
forwardAvatarImageView.isHidden = false
|
||||
forwardAvatarInitialLabel.isHidden = true
|
||||
forwardAvatarView.backgroundColor = .clear
|
||||
} else {
|
||||
forwardAvatarImageView.image = nil
|
||||
forwardAvatarImageView.isHidden = true
|
||||
forwardAvatarInitialLabel.isHidden = false
|
||||
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
|
||||
)
|
||||
}
|
||||
} else {
|
||||
forwardLabel.isHidden = true
|
||||
forwardAvatarView.isHidden = true
|
||||
@@ -894,6 +930,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
if layout.isForward {
|
||||
forwardLabel.frame = layout.forwardHeaderFrame
|
||||
forwardAvatarView.frame = layout.forwardAvatarFrame
|
||||
let avatarBounds = forwardAvatarView.bounds
|
||||
forwardAvatarInitialLabel.frame = avatarBounds
|
||||
forwardAvatarImageView.frame = avatarBounds
|
||||
forwardNameLabel.frame = layout.forwardNameFrame
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ final class NativeMessageListController: UIViewController {
|
||||
var replyText: String?
|
||||
var replyMessageId: String?
|
||||
var forwardSenderName: String?
|
||||
var forwardSenderKey: String?
|
||||
|
||||
if let att = replyAtt {
|
||||
if let data = att.blob.data(using: .utf8),
|
||||
@@ -293,6 +294,7 @@ final class NativeMessageListController: UIViewController {
|
||||
if displayText.isEmpty {
|
||||
// Forward
|
||||
forwardSenderName = name
|
||||
forwardSenderKey = senderKey
|
||||
} else {
|
||||
// Reply quote
|
||||
replyName = name
|
||||
@@ -312,7 +314,8 @@ final class NativeMessageListController: UIViewController {
|
||||
replyName: replyName,
|
||||
replyText: replyText,
|
||||
replyMessageId: replyMessageId,
|
||||
forwardSenderName: forwardSenderName
|
||||
forwardSenderName: forwardSenderName,
|
||||
forwardSenderKey: forwardSenderKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user