Форвард: Telegram-parity UI — правильный размер бабла, текст/таймстамп, аватарка с инициалами, отступы

This commit is contained in:
2026-03-31 16:36:58 +05:00
parent e5179b11ea
commit 464fae37a9
11 changed files with 1037 additions and 53 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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