From 6b55baacd83e41ded9cfd47178a9621f9e31726c Mon Sep 17 00:00:00 2001 From: senseiGai Date: Tue, 31 Mar 2026 02:48:28 +0500 Subject: [PATCH] =?UTF-8?q?Telegram-style=20date=20pills=20=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82-=D0=BB=D0=B8=D1=81=D1=82=D0=B5=20=E2=80=94=20stick?= =?UTF-8?q?y=20headers=20=D1=81=20push-=D0=BF=D0=B5=D1=80=D0=B5=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BC=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Repositories/MessageRepository.swift | 16 +- Rosetta/Core/Layout/MessageCellLayout.swift | 83 ++++++- Rosetta/Core/Services/SessionManager.swift | 11 +- .../Chats/ChatDetail/MessageCellView.swift | 2 +- .../Chats/ChatDetail/NativeMessageCell.swift | 43 +++- .../Chats/ChatDetail/NativeMessageList.swift | 222 +++++++++++++++++- .../TelegramContextMenuController.swift | 6 +- .../Features/Chats/ChatList/ChatRowView.swift | 29 ++- Rosetta/RosettaApp.swift | 22 +- .../NotificationService.swift | 16 +- 10 files changed, 408 insertions(+), 42 deletions(-) diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index e9a9ae0..68551f4 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -858,11 +858,23 @@ final class MessageRepository: ObservableObject { let plainText: String if !privateKey.isEmpty { - if let data = try? CryptoManager.shared.decryptWithPassword(record.text, password: privateKey), + // Prefer requireCompression: true — encryptWithPassword always uses rawDeflate, + // so decompression acts as verification. The uncompressed fallback (~1/256 + // false-positive chance with wrong key) can return AES garbage. + if let data = try? CryptoManager.shared.decryptWithPassword( + record.text, password: privateKey, requireCompression: true + ), let decrypted = String(data: data, encoding: .utf8) { plainText = decrypted + } else if let data = try? CryptoManager.shared.decryptWithPassword( + record.text, password: privateKey + ), + let decrypted = String(data: data, encoding: .utf8), + !Self.isProbablyEncryptedPayload(decrypted) { + // Uncompressed fallback for legacy messages — but reject if result + // looks like ciphertext (false-positive AES with wrong key). + plainText = decrypted } else { - // Android parity: safePlainMessageFallback() — return "" if ciphertext, raw if plaintext let fallback = Self.safePlainMessageFallback(record.text) #if DEBUG if !fallback.isEmpty { diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index adbc6e0..21f9557 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -67,6 +67,12 @@ struct MessageCellLayout: Sendable { let forwardAvatarFrame: CGRect let forwardNameFrame: CGRect + // MARK: - Date Header (optional) + + let showsDateHeader: Bool + let dateHeaderText: String + let dateHeaderHeight: CGFloat + // MARK: - Types enum MessageType: Sendable { @@ -102,6 +108,8 @@ extension MessageCellLayout { let forwardImageCount: Int let forwardFileCount: Int let forwardCaption: String? + let showsDateHeader: Bool + let dateHeaderText: String } private struct MediaDimensions { @@ -403,11 +411,14 @@ extension MessageCellLayout { // Stretchable bubble image min height bubbleH = max(bubbleH, 37) - let totalH = groupGap + bubbleH + // Date header adds height above the bubble. + let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0 + + let totalH = dateHeaderH + groupGap + bubbleH // Bubble X (approximate — overridden in layoutSubviews with actual cellWidth) let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8 - let bubbleFrame = CGRect(x: bubbleX, y: groupGap, width: bubbleW, height: bubbleH) + let bubbleFrame = CGRect(x: bubbleX, y: dateHeaderH + groupGap, width: bubbleW, height: bubbleH) // ── STEP 5: Geometry assignment ── @@ -565,7 +576,10 @@ extension MessageCellLayout { isForward: config.isForward, forwardHeaderFrame: fwdHeaderFrame, forwardAvatarFrame: fwdAvatarFrame, - forwardNameFrame: fwdNameFrame + forwardNameFrame: fwdNameFrame, + showsDateHeader: config.showsDateHeader, + dateHeaderText: config.dateHeaderText, + dateHeaderHeight: dateHeaderH ) return (layout, cachedTextLayout) } @@ -727,6 +741,13 @@ extension MessageCellLayout { return false } + // Break groups at day boundaries (date separator will appear between them). + let msgDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) + let neighborDate = Date(timeIntervalSince1970: TimeInterval(neighbor.timestamp) / 1000) + if !Calendar.current.isDate(msgDate, inSameDayAs: neighborDate) { + return false + } + // Long gaps should split groups. if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs { return false @@ -769,11 +790,33 @@ extension MessageCellLayout { timestampFormatter.locale = .autoupdatingCurrent timestampFormatter.timeZone = .autoupdatingCurrent + let calendar = Calendar.current + let now = Date() + let sameYearFormatter = DateFormatter() + sameYearFormatter.dateFormat = "MMMM d" + sameYearFormatter.locale = .autoupdatingCurrent + let diffYearFormatter = DateFormatter() + diffYearFormatter.dateFormat = "MMMM d, yyyy" + diffYearFormatter.locale = .autoupdatingCurrent + for (index, message) in messages.enumerated() { let isOutgoing = message.fromPublicKey == currentPublicKey - let timestampText = timestampFormatter.string( - from: Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) - ) + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) + let timestampText = timestampFormatter.string(from: messageDate) + + // Date header: show on first message of each calendar day + let showsDateHeader: Bool + if index == 0 { + showsDateHeader = true + } else { + let prevDate = Date(timeIntervalSince1970: TimeInterval(messages[index - 1].timestamp) / 1000) + showsDateHeader = !calendar.isDate(messageDate, inSameDayAs: prevDate) + } + let dateHeaderText = showsDateHeader + ? Self.formatDateHeader(messageDate, now: now, calendar: calendar, + sameYearFormatter: sameYearFormatter, + diffYearFormatter: diffYearFormatter) + : "" // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text @@ -843,7 +886,9 @@ extension MessageCellLayout { isForward: isForward, forwardImageCount: isForward ? images.count : 0, forwardFileCount: isForward ? files.count : 0, - forwardCaption: nil + forwardCaption: nil, + showsDateHeader: showsDateHeader, + dateHeaderText: dateHeaderText ) let (layout, textLayout) = calculate(config: config) @@ -855,6 +900,30 @@ extension MessageCellLayout { } } +// MARK: - Date Header Formatting (Thread-Safe) + +extension MessageCellLayout { + /// Telegram-style date header text: Today / Yesterday / March 8 / March 8, 2025 + static func formatDateHeader( + _ date: Date, + now: Date, + calendar: Calendar, + sameYearFormatter: DateFormatter, + diffYearFormatter: DateFormatter + ) -> String { + if calendar.isDateInToday(date) { + return "Today" + } + if calendar.isDateInYesterday(date) { + return "Yesterday" + } + if calendar.component(.year, from: date) == calendar.component(.year, from: now) { + return sameYearFormatter.string(from: date) + } + return diffYearFormatter.string(from: date) + } +} + // MARK: - Geometry Helpers private extension CGSize { diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 4ce335f..358e910 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1506,6 +1506,9 @@ final class SessionManager { 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. + DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) return } let text = cryptoResult.text @@ -1804,9 +1807,15 @@ final class SessionManager { return nil } - guard let privateKeyHex, !packet.content.isEmpty else { + guard let privateKeyHex else { return nil } + // Allow empty content for messages with attachments (photo-only, call, etc.). + // Normally content is always non-empty (XChaCha20 of "" still produces ciphertext), + // but buggy senders or edge cases may send empty content with valid attachments. + if packet.content.isEmpty { + return ("", nil) + } // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce). if isOwnMessage, !packet.aesChachaKey.isEmpty { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 206f941..bf70284 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -59,7 +59,7 @@ struct MessageCellView: View, Equatable { } .modifier(ConditionalSwipeToReply( enabled: !isSavedMessages && !isSystemAccount - && !message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }), + && !message.attachments.contains(where: { $0.type == .avatar }), onReply: { actions.onReply(message) } )) .overlay { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index bb16c5b..36315b2 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -77,6 +77,10 @@ final class NativeMessageCell: UICollectionViewCell { // MARK: - Subviews (always present, hidden when unused) + // Date separator header (Telegram-style centered pill) + private let dateHeaderContainer = UIView() + private let dateHeaderLabel = UILabel() + // Bubble — uses Telegram-exact stretchable image for the fill (raster tail) // + CAShapeLayer for shadow path (approximate, vector) private let bubbleView = UIView() @@ -100,6 +104,7 @@ final class NativeMessageCell: UICollectionViewCell { private let replyBar = UIView() private let replyNameLabel = UILabel() private let replyTextLabel = UILabel() + private var replyMessageId: String? // Photo collage (up to 5 tiles) private let photoContainer = UIView() @@ -146,9 +151,11 @@ final class NativeMessageCell: UICollectionViewCell { private var message: ChatMessage? private var actions: MessageCellActions? - private var currentLayout: MessageCellLayout? + private(set) var currentLayout: MessageCellLayout? var isSavedMessages = false var isSystemAccount = false + /// When true, the inline date header pill is hidden (floating sticky one covers it). + var isInlineDateHeaderHidden = false private var isDeliveryFailedVisible = false private var wasSentCheckVisible = false private var wasReadCheckVisible = false @@ -180,6 +187,19 @@ final class NativeMessageCell: UICollectionViewCell { clipsToBounds = false contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip + // Date header pill (Telegram-style centered date separator with glass) + dateHeaderContainer.clipsToBounds = false + dateHeaderContainer.isHidden = true + let inlineGlass = TelegramGlassUIView(frame: .zero) + inlineGlass.isUserInteractionEnabled = false + inlineGlass.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dateHeaderContainer.addSubview(inlineGlass) + dateHeaderLabel.font = UIFont.systemFont(ofSize: 13, weight: .medium) + dateHeaderLabel.textColor = .white + dateHeaderLabel.textAlignment = .center + dateHeaderContainer.addSubview(dateHeaderLabel) + contentView.addSubview(dateHeaderContainer) + // Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top bubbleLayer.fillColor = UIColor.clear.cgColor bubbleLayer.fillRule = .nonZero @@ -229,6 +249,9 @@ final class NativeMessageCell: UICollectionViewCell { replyTextLabel.font = Self.replyTextFont replyTextLabel.lineBreakMode = .byTruncatingTail replyContainer.addSubview(replyTextLabel) + let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped)) + replyContainer.addGestureRecognizer(replyTap) + replyContainer.isUserInteractionEnabled = true bubbleView.addSubview(replyContainer) // Photo collage @@ -421,10 +444,12 @@ final class NativeMessageCell: UICollectionViewCell { actions: MessageCellActions, replyName: String? = nil, replyText: String? = nil, + replyMessageId: String? = nil, forwardSenderName: String? = nil ) { self.message = message self.actions = actions + self.replyMessageId = replyMessageId let isOutgoing = currentLayout?.isOutgoing ?? false let isMediaStatus: Bool = { @@ -684,6 +709,11 @@ final class NativeMessageCell: UICollectionViewCell { let cellW = contentView.bounds.width let tailProtrusion = Self.bubbleMetrics.tailProtrusion + // Inline date pills are always hidden — floating overlay pills in + // NativeMessageListController handle all date display + push mechanics. + // Cell still reserves dateHeaderHeight for visual spacing between sections. + dateHeaderContainer.isHidden = true + // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment let bubbleX: CGFloat if layout.isOutgoing { @@ -693,7 +723,7 @@ final class NativeMessageCell: UICollectionViewCell { } bubbleView.frame = CGRect( - x: bubbleX, y: layout.groupGap, + x: bubbleX, y: layout.bubbleFrame.minY, width: layout.bubbleSize.width, height: layout.bubbleSize.height ) @@ -1274,6 +1304,11 @@ final class NativeMessageCell: UICollectionViewCell { } } + @objc private func replyQuoteTapped() { + guard let replyMessageId, let actions else { return } + actions.onScrollToMessage(replyMessageId) + } + @objc private func fileContainerTapped() { guard let message, let actions else { return } let isCallType = message.attachments.contains { $0.type == .call } @@ -1990,6 +2025,9 @@ final class NativeMessageCell: UICollectionViewCell { layer.removeAnimation(forKey: "insertionSlide") layer.removeAnimation(forKey: "insertionMove") contentView.layer.removeAnimation(forKey: "insertionAlpha") + dateHeaderContainer.isHidden = true + dateHeaderLabel.text = nil + isInlineDateHeaderHidden = false message = nil actions = nil currentLayout = nil @@ -2013,6 +2051,7 @@ final class NativeMessageCell: UICollectionViewCell { statusBackgroundView.isHidden = true resetPhotoTiles() replyContainer.isHidden = true + replyMessageId = nil fileContainer.isHidden = true callArrowView.isHidden = true callBackButton.isHidden = true diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index e363297..43076cb 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -104,6 +104,11 @@ final class NativeMessageListController: UIViewController { /// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback. private var lastReportedAtBottom: Bool = true + // MARK: - Floating Date Headers (Telegram-style per-section pills) + private var datePillPool: [(container: UIView, label: UILabel)] = [] + private var dateHideTimer: Timer? + private var areDatePillsVisible = false + // MARK: - Empty State (UIKit-managed, animates with keyboard) private var emptyStateHosting: UIHostingController? private var emptyStateGuide: UILayoutGuide? @@ -136,6 +141,7 @@ final class NativeMessageListController: UIViewController { setupCollectionView() setupNativeCellRegistration() setupDataSource() + setupFloatingDateHeader() // Create pure UIKit composer after view hierarchy is ready. if shouldSetupComposer { @@ -188,6 +194,9 @@ final class NativeMessageListController: UIViewController { } // ComposerView reports height via delegate (composerHeightDidChange). // No polling needed — pure UIKit, no UIHostingController inflation bug. + + // Date pills sit between collection view and composer (z-order). + // Composer covers pills naturally — no bringSubviewToFront needed. } override func viewSafeAreaInsetsDidChange() { @@ -260,6 +269,7 @@ final class NativeMessageListController: UIViewController { let replyAtt = message.attachments.first { $0.type == .messages } var replyName: String? var replyText: String? + var replyMessageId: String? var forwardSenderName: String? if let att = replyAtt { @@ -287,6 +297,7 @@ final class NativeMessageListController: UIViewController { // Reply quote replyName = name replyText = first.message.isEmpty ? "Photo" : first.message + replyMessageId = first.message_id } } } @@ -300,6 +311,7 @@ final class NativeMessageListController: UIViewController { actions: self.config.actions, replyName: replyName, replyText: replyText, + replyMessageId: replyMessageId, forwardSenderName: forwardSenderName ) } @@ -390,7 +402,7 @@ final class NativeMessageListController: UIViewController { let container = UIView(frame: .zero) container.translatesAutoresizingMaskIntoConstraints = false container.backgroundColor = .clear - container.clipsToBounds = true + container.clipsToBounds = false container.isUserInteractionEnabled = true view.addSubview(container) @@ -417,7 +429,7 @@ final class NativeMessageListController: UIViewController { // transform matrix during interactive keyboard dismiss. let button = UIButton(type: .custom) button.frame = rect - button.clipsToBounds = true + button.clipsToBounds = false button.alpha = 0 button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0) button.layer.allowsEdgeAntialiasing = true @@ -548,6 +560,210 @@ final class NativeMessageListController: UIViewController { } } + // MARK: - Floating Date Headers (Telegram-style per-section pills) + + /// Create a reusable date pill overlay (glass + label). + private func makeDatePill() -> (container: UIView, label: UILabel) { + let c = UIView() + c.alpha = 0 + c.isHidden = true + c.isUserInteractionEnabled = false + let glass = TelegramGlassUIView(frame: .zero) + glass.isUserInteractionEnabled = false + // NO autoresizingMask — frame set explicitly in updateFloatingDateHeader + // to prevent 1-frame inflation when pool pill reuses with different text. + glass.tag = 42 // marker to find glass subview later + c.addSubview(glass) + let l = UILabel() + l.font = UIFont.systemFont(ofSize: 12, weight: .medium) + l.textColor = .white + l.textAlignment = .center + c.addSubview(l) + return (c, l) + } + + private func setupFloatingDateHeader() { + // Pre-create pool of 4 pills (max visible date sections at once). + for _ in 0..<4 { + let pill = makeDatePill() + view.addSubview(pill.container) + datePillPool.append(pill) + } + } + + /// Telegram-exact sticky date header positioning. + /// + /// Core formula from Telegram ListView (`.bottom` stick direction): + /// `headerY = min(max(sectionTopY, stickyY), sectionBottomY - pillH)` + /// + /// This single formula handles natural position, sticky, AND push between + /// adjacent sections — no separate "stuck" vs "approach" logic needed. + private func updateFloatingDateHeader() { + guard !messages.isEmpty, collectionView != nil else { return } + + let pillH: CGFloat = 24 + let hPad: CGFloat = 7 + let stickyY = view.safeAreaInsets.top + 8 + + // 1. Group visible cells by date → section ranges in screen coords. + struct DateSection { + let text: String + var topY: CGFloat // visual top of section (oldest msg = smallest Y) + var bottomY: CGFloat // visual bottom of section (newest msg = largest Y) + } + + // Build date for each visible cell, collect section ranges. + var sectionMap: [String: (topY: CGFloat, bottomY: CGFloat)] = [:] + let calendar = Calendar.current + let now = Date() + + for cell in collectionView.visibleCells { + guard let nativeCell = cell as? NativeMessageCell, + let layout = nativeCell.currentLayout else { continue } + // Determine this cell's date text + // Use the layout's dateHeaderText if available, else compute from message + let cellFrame = collectionView.convert(cell.frame, to: view) + + // We need the date text for this cell. Find its message. + guard let ip = collectionView.indexPath(for: cell) else { continue } + let msgIndex = messages.count - 1 - ip.item + guard msgIndex >= 0, msgIndex < messages.count else { continue } + let msgDate = Date(timeIntervalSince1970: TimeInterval(messages[msgIndex].timestamp) / 1000) + let dateText = Self.formatDateText(msgDate) + + if var existing = sectionMap[dateText] { + existing.topY = min(existing.topY, cellFrame.minY) + existing.bottomY = max(existing.bottomY, cellFrame.maxY) + sectionMap[dateText] = existing + } else { + sectionMap[dateText] = (cellFrame.minY, cellFrame.maxY) + } + } + + // Sort sections by topY (top to bottom on screen). + let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) } + .sorted { $0.topY < $1.topY } + + // 2. Position each section's pill using Telegram's formula. + var usedPillCount = 0 + for section in sections { + guard usedPillCount < datePillPool.count else { break } + + // Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH) + // +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9 + let naturalY = section.topY + 9 + let headerY = min(max(naturalY, stickyY), section.bottomY - pillH) + + // Is this pill stuck (clamped to stickyY) or at natural position? + let isStuck = naturalY < stickyY && headerY <= stickyY + + // Skip only if pill is completely above screen. + if headerY + pillH < 0 { continue } + + let pill = datePillPool[usedPillCount] + + // Prevent resize animation when reusing pool pill with different text. + CATransaction.begin() + CATransaction.setDisableActions(true) + pill.label.text = section.text + pill.label.sizeToFit() + let textW = ceil(pill.label.intrinsicContentSize.width) + let pillW = textW + hPad * 2 + let screenW = UIScreen.main.bounds.width + let pillFrame = CGRect( + x: round((screenW - pillW) / 2), y: headerY, + width: pillW, height: pillH + ) + pill.container.frame = pillFrame + pill.container.layer.cornerRadius = pillH / 2 + pill.label.frame = pill.container.bounds + // Explicitly set glass frame (no autoresizingMask — prevents 1-frame inflation). + if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) { + glass.frame = pill.container.bounds + glass.layoutIfNeeded() + } + pill.container.isHidden = false + // Natural-position pills always visible. Stuck pills fade with timer. + pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1 + pill.container.tag = isStuck ? 1 : 0 + CATransaction.commit() + + usedPillCount += 1 + } + + // Hide unused pills. + for i in usedPillCount.. 0 { + showDatePills() + } + } + + private static func formatDateText(_ date: Date) -> String { + let calendar = Calendar.current + if calendar.isDateInToday(date) { return "Today" } + if calendar.isDateInYesterday(date) { return "Yesterday" } + let now = Date() + if calendar.component(.year, from: date) == calendar.component(.year, from: now) { + return sameYearDateFormatter.string(from: date) + } + return diffYearDateFormatter.string(from: date) + } + + private func showDatePills() { + dateHideTimer?.invalidate() + if !areDatePillsVisible { + areDatePillsVisible = true + UIView.animate(withDuration: 0.2) { + for pill in self.datePillPool where !pill.container.isHidden { + // tag=1 → stuck pill (fade in). tag=0 → natural (already alpha=1). + if pill.container.tag == 1 { pill.container.alpha = 1 } + } + } + } + dateHideTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in + self?.hideDatePills() + } + } + + private func hideDatePills() { + guard areDatePillsVisible else { return } + areDatePillsVisible = false + UIView.animate(withDuration: 0.4) { + for pill in self.datePillPool where !pill.container.isHidden { + // Only fade STUCK pills (tag=1). Natural pills stay visible. + if pill.container.tag == 1 { pill.container.alpha = 0 } + } + } + } + + private static let sameYearDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM d" + f.locale = .autoupdatingCurrent + return f + }() + + private static let diffYearDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM d, yyyy" + f.locale = .autoupdatingCurrent + return f + }() + // MARK: - Empty State (UIKit-managed, animates with keyboard) func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) { @@ -957,6 +1173,8 @@ final class NativeMessageListController: UIViewController { extension NativeMessageListController: UICollectionViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateFloatingDateHeader() + let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top let isAtBottom = offsetFromBottom < 50 updateScrollToBottomBadge() diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift index a946351..711820d 100644 --- a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift @@ -21,10 +21,8 @@ enum TelegramContextMenuBuilder { ) -> [TelegramContextMenuItem] { var items: [TelegramContextMenuItem] = [] - let isAvatarOrForwarded = message.attachments.contains(where: { - $0.type == .avatar || $0.type == .messages - }) - let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded + let isAvatar = message.attachments.contains(where: { $0.type == .avatar }) + let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatar if canReplyForward { items.append(TelegramContextMenuItem( diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 15cd6c5..8edf086 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -154,7 +154,13 @@ private extension ChatRowView { if isTyping && !dialog.isSavedMessages { return "typing..." } - if dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + if raw.isEmpty { + return "No messages yet" + } + // Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user. + // This catches stale data persisted before isGarbageText was improved. + if Self.looksLikeCiphertext(raw) { return "No messages yet" } if let cached = Self.messageTextCache[dialog.lastMessage] { @@ -170,6 +176,27 @@ private extension ChatRowView { Self.messageTextCache[dialog.lastMessage] = result return result } + + /// Detects encrypted payload formats that should never be shown in UI. + private static func looksLikeCiphertext(_ text: String) -> Bool { + // CHNK: chunked format + if text.hasPrefix("CHNK:") { return true } + // ivBase64:ctBase64 or hex-encoded XChaCha20 ciphertext + let parts = text.components(separatedBy: ":") + if parts.count == 2 { + let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) + let bothBase64 = parts.allSatisfy { part in + part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } + } + if bothBase64 { return true } + } + // Pure hex string (≥40 chars, only hex digits) — XChaCha20 wire format + if text.count >= 40 { + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + if text.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true } + } + return false + } } // MARK: - Trailing Column diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index cc8162f..667ef66 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -116,8 +116,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let shared = UserDefaults(suiteName: "group.com.rosetta.dev") // MARK: Sender identification - // Server sends `from` = sender public key (personal_message) or group ID (group_message). - let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo) + // Server sends `dialog` = sender public key (personal_message) or group ID (group_message). + let senderKey = userInfo["dialog"] as? String + ?? Self.extractSenderKey(from: userInfo) // Resolve sender display name from App Group cache (synced by DialogRepository). let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] @@ -143,17 +144,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? [] let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey) - // If server sent visible alert, NSE handles sound+badge. Just sync badge. - // If muted, wake app but don't show notification. + // If server sent visible alert, NSE handles sound+badge — don't double-count. + // If muted, wake app but don't show notification (NSE also suppresses muted). if hasVisibleAlert || isMuted { - if !isMuted { - // Increment badge only for non-muted visible alerts. - let currentBadge = shared?.integer(forKey: "app_badge_count") ?? 0 - let newBadge = currentBadge + 1 - shared?.set(newBadge, forKey: "app_badge_count") - UserDefaults.standard.set(newBadge, forKey: "app_badge_count") - UNUserNotificationCenter.current().setBadgeCount(newBadge) - } completionHandler(.newData) return } @@ -276,7 +269,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - let callerKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo) + let callerKey = userInfo["dialog"] as? String + ?? Self.extractSenderKey(from: userInfo) guard !callerKey.isEmpty else { completionHandler(.noData) return @@ -314,7 +308,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent /// this helper is a fallback for other contexts (notification tap, etc.). private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { firstNonBlank(userInfo, keys: [ - "from", "sender_public_key", "from_public_key", "fromPublicKey", + "dialog", "sender_public_key", "from_public_key", "fromPublicKey", "public_key", "publicKey" ]) ?? "" } diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index 38330f8..f4fc977 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -14,9 +14,9 @@ final class NotificationService: UNNotificationServiceExtension { private static let badgeKey = "app_badge_count" /// Android parity: multiple key names for sender public key extraction. - /// Server currently sends `from` field in data-only push. + /// Server sends `dialog` field (was `from`). Both kept for backward compat. private static let senderKeyNames = [ - "from", "sender_public_key", "from_public_key", "fromPublicKey", + "dialog", "sender_public_key", "from_public_key", "fromPublicKey", "public_key", "publicKey" ] private static let senderNameKeyNames = [ @@ -86,7 +86,7 @@ final class NotificationService: UNNotificationServiceExtension { content.sound = .default content.categoryIdentifier = "call" - let callerKey = content.userInfo["from"] as? String + let callerKey = content.userInfo["dialog"] as? String ?? Self.extractSenderKey(from: content.userInfo) let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let callerName = contactNames[callerKey] @@ -116,8 +116,8 @@ final class NotificationService: UNNotificationServiceExtension { // 1. Add sound for vibration — server APNs payload has no sound field. content.sound = .default - // 2. Extract sender key — server sends `from` field. - let senderKey = content.userInfo["from"] as? String + // 2. Extract sender key — server sends `dialog` field (was `from`). + let senderKey = content.userInfo["dialog"] as? String ?? Self.extractSenderKey(from: content.userInfo) // 3. Filter muted chats BEFORE badge increment — muted chats must not inflate badge. @@ -151,9 +151,9 @@ final class NotificationService: UNNotificationServiceExtension { ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames) if let resolvedName, !resolvedName.isEmpty { updatedInfo["sender_name"] = resolvedName - if content.title.isEmpty { - content.title = resolvedName - } + // Always prefer local name — server sends title at push time, + // but user may have a custom contact name in App Group cache. + content.title = resolvedName } content.userInfo = updatedInfo