Telegram-style date pills в чат-листе — sticky headers с push-переходом между секциями

This commit is contained in:
2026-03-31 02:48:28 +05:00
parent 3fc15c14ff
commit 6b55baacd8
10 changed files with 408 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<EmptyChatContent>?
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..<datePillPool.count {
datePillPool[i].container.isHidden = true
}
#if DEBUG
if !sections.isEmpty {
let desc = sections.enumerated().map { i, s in
let y = min(max(s.topY + 6, stickyY), s.bottomY - pillH)
return "\(s.text)@\(Int(y))[\(Int(s.topY))\(Int(s.bottomY))]"
}.joined(separator: " | ")
print("📅 pills=\(usedPillCount) \(desc)")
}
#endif
// 3. Show/hide with timer.
if 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()

View File

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

View File

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

View File

@@ -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"
]) ?? ""
}

View File

@@ -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,10 +151,10 @@ final class NotificationService: UNNotificationServiceExtension {
?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
if let resolvedName, !resolvedName.isEmpty {
updatedInfo["sender_name"] = resolvedName
if content.title.isEmpty {
// 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
// 7. Ensure notification category for CarPlay parity.