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 let plainText: String
if !privateKey.isEmpty { 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) { let decrypted = String(data: data, encoding: .utf8) {
plainText = decrypted 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 { } else {
// Android parity: safePlainMessageFallback() return "" if ciphertext, raw if plaintext
let fallback = Self.safePlainMessageFallback(record.text) let fallback = Self.safePlainMessageFallback(record.text)
#if DEBUG #if DEBUG
if !fallback.isEmpty { if !fallback.isEmpty {

View File

@@ -67,6 +67,12 @@ struct MessageCellLayout: Sendable {
let forwardAvatarFrame: CGRect let forwardAvatarFrame: CGRect
let forwardNameFrame: CGRect let forwardNameFrame: CGRect
// MARK: - Date Header (optional)
let showsDateHeader: Bool
let dateHeaderText: String
let dateHeaderHeight: CGFloat
// MARK: - Types // MARK: - Types
enum MessageType: Sendable { enum MessageType: Sendable {
@@ -102,6 +108,8 @@ extension MessageCellLayout {
let forwardImageCount: Int let forwardImageCount: Int
let forwardFileCount: Int let forwardFileCount: Int
let forwardCaption: String? let forwardCaption: String?
let showsDateHeader: Bool
let dateHeaderText: String
} }
private struct MediaDimensions { private struct MediaDimensions {
@@ -403,11 +411,14 @@ extension MessageCellLayout {
// Stretchable bubble image min height // Stretchable bubble image min height
bubbleH = max(bubbleH, 37) 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) // Bubble X (approximate overridden in layoutSubviews with actual cellWidth)
let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8 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 // STEP 5: Geometry assignment
@@ -565,7 +576,10 @@ extension MessageCellLayout {
isForward: config.isForward, isForward: config.isForward,
forwardHeaderFrame: fwdHeaderFrame, forwardHeaderFrame: fwdHeaderFrame,
forwardAvatarFrame: fwdAvatarFrame, forwardAvatarFrame: fwdAvatarFrame,
forwardNameFrame: fwdNameFrame forwardNameFrame: fwdNameFrame,
showsDateHeader: config.showsDateHeader,
dateHeaderText: config.dateHeaderText,
dateHeaderHeight: dateHeaderH
) )
return (layout, cachedTextLayout) return (layout, cachedTextLayout)
} }
@@ -727,6 +741,13 @@ extension MessageCellLayout {
return false 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. // Long gaps should split groups.
if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs { if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
return false return false
@@ -769,11 +790,33 @@ extension MessageCellLayout {
timestampFormatter.locale = .autoupdatingCurrent timestampFormatter.locale = .autoupdatingCurrent
timestampFormatter.timeZone = .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() { for (index, message) in messages.enumerated() {
let isOutgoing = message.fromPublicKey == currentPublicKey let isOutgoing = message.fromPublicKey == currentPublicKey
let timestampText = timestampFormatter.string( let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
from: 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) // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
@@ -843,7 +886,9 @@ extension MessageCellLayout {
isForward: isForward, isForward: isForward,
forwardImageCount: isForward ? images.count : 0, forwardImageCount: isForward ? images.count : 0,
forwardFileCount: isForward ? files.count : 0, forwardFileCount: isForward ? files.count : 0,
forwardCaption: nil forwardCaption: nil,
showsDateHeader: showsDateHeader,
dateHeaderText: dateHeaderText
) )
let (layout, textLayout) = calculate(config: config) 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 // MARK: - Geometry Helpers
private extension CGSize { private extension CGSize {

View File

@@ -1506,6 +1506,9 @@ final class SessionManager {
guard let cryptoResult else { guard let cryptoResult else {
Self.logger.warning("processIncoming: decryptIncomingMessage returned nil for msgId=\(packet.messageId.prefix(8))") 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 return
} }
let text = cryptoResult.text let text = cryptoResult.text
@@ -1804,9 +1807,15 @@ final class SessionManager {
return nil return nil
} }
guard let privateKeyHex, !packet.content.isEmpty else { guard let privateKeyHex else {
return nil 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). // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce).
if isOwnMessage, !packet.aesChachaKey.isEmpty { if isOwnMessage, !packet.aesChachaKey.isEmpty {

View File

@@ -59,7 +59,7 @@ struct MessageCellView: View, Equatable {
} }
.modifier(ConditionalSwipeToReply( .modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount enabled: !isSavedMessages && !isSystemAccount
&& !message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }), && !message.attachments.contains(where: { $0.type == .avatar }),
onReply: { actions.onReply(message) } onReply: { actions.onReply(message) }
)) ))
.overlay { .overlay {

View File

@@ -77,6 +77,10 @@ final class NativeMessageCell: UICollectionViewCell {
// MARK: - Subviews (always present, hidden when unused) // 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) // Bubble uses Telegram-exact stretchable image for the fill (raster tail)
// + CAShapeLayer for shadow path (approximate, vector) // + CAShapeLayer for shadow path (approximate, vector)
private let bubbleView = UIView() private let bubbleView = UIView()
@@ -100,6 +104,7 @@ final class NativeMessageCell: UICollectionViewCell {
private let replyBar = UIView() private let replyBar = UIView()
private let replyNameLabel = UILabel() private let replyNameLabel = UILabel()
private let replyTextLabel = UILabel() private let replyTextLabel = UILabel()
private var replyMessageId: String?
// Photo collage (up to 5 tiles) // Photo collage (up to 5 tiles)
private let photoContainer = UIView() private let photoContainer = UIView()
@@ -146,9 +151,11 @@ final class NativeMessageCell: UICollectionViewCell {
private var message: ChatMessage? private var message: ChatMessage?
private var actions: MessageCellActions? private var actions: MessageCellActions?
private var currentLayout: MessageCellLayout? private(set) var currentLayout: MessageCellLayout?
var isSavedMessages = false var isSavedMessages = false
var isSystemAccount = 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 isDeliveryFailedVisible = false
private var wasSentCheckVisible = false private var wasSentCheckVisible = false
private var wasReadCheckVisible = false private var wasReadCheckVisible = false
@@ -180,6 +187,19 @@ final class NativeMessageCell: UICollectionViewCell {
clipsToBounds = false clipsToBounds = false
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip 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 // Bubble CAShapeLayer for shadow (index 0), then outline, then raster image on top
bubbleLayer.fillColor = UIColor.clear.cgColor bubbleLayer.fillColor = UIColor.clear.cgColor
bubbleLayer.fillRule = .nonZero bubbleLayer.fillRule = .nonZero
@@ -229,6 +249,9 @@ final class NativeMessageCell: UICollectionViewCell {
replyTextLabel.font = Self.replyTextFont replyTextLabel.font = Self.replyTextFont
replyTextLabel.lineBreakMode = .byTruncatingTail replyTextLabel.lineBreakMode = .byTruncatingTail
replyContainer.addSubview(replyTextLabel) replyContainer.addSubview(replyTextLabel)
let replyTap = UITapGestureRecognizer(target: self, action: #selector(replyQuoteTapped))
replyContainer.addGestureRecognizer(replyTap)
replyContainer.isUserInteractionEnabled = true
bubbleView.addSubview(replyContainer) bubbleView.addSubview(replyContainer)
// Photo collage // Photo collage
@@ -421,10 +444,12 @@ final class NativeMessageCell: UICollectionViewCell {
actions: MessageCellActions, actions: MessageCellActions,
replyName: String? = nil, replyName: String? = nil,
replyText: String? = nil, replyText: String? = nil,
replyMessageId: String? = nil,
forwardSenderName: String? = nil forwardSenderName: String? = nil
) { ) {
self.message = message self.message = message
self.actions = actions self.actions = actions
self.replyMessageId = replyMessageId
let isOutgoing = currentLayout?.isOutgoing ?? false let isOutgoing = currentLayout?.isOutgoing ?? false
let isMediaStatus: Bool = { let isMediaStatus: Bool = {
@@ -684,6 +709,11 @@ final class NativeMessageCell: UICollectionViewCell {
let cellW = contentView.bounds.width let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion 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 // Rule 2: Tail reserve (6pt) + margin (2pt) strict vertical body alignment
let bubbleX: CGFloat let bubbleX: CGFloat
if layout.isOutgoing { if layout.isOutgoing {
@@ -693,7 +723,7 @@ final class NativeMessageCell: UICollectionViewCell {
} }
bubbleView.frame = CGRect( bubbleView.frame = CGRect(
x: bubbleX, y: layout.groupGap, x: bubbleX, y: layout.bubbleFrame.minY,
width: layout.bubbleSize.width, height: layout.bubbleSize.height 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() { @objc private func fileContainerTapped() {
guard let message, let actions else { return } guard let message, let actions else { return }
let isCallType = message.attachments.contains { $0.type == .call } let isCallType = message.attachments.contains { $0.type == .call }
@@ -1990,6 +2025,9 @@ final class NativeMessageCell: UICollectionViewCell {
layer.removeAnimation(forKey: "insertionSlide") layer.removeAnimation(forKey: "insertionSlide")
layer.removeAnimation(forKey: "insertionMove") layer.removeAnimation(forKey: "insertionMove")
contentView.layer.removeAnimation(forKey: "insertionAlpha") contentView.layer.removeAnimation(forKey: "insertionAlpha")
dateHeaderContainer.isHidden = true
dateHeaderLabel.text = nil
isInlineDateHeaderHidden = false
message = nil message = nil
actions = nil actions = nil
currentLayout = nil currentLayout = nil
@@ -2013,6 +2051,7 @@ final class NativeMessageCell: UICollectionViewCell {
statusBackgroundView.isHidden = true statusBackgroundView.isHidden = true
resetPhotoTiles() resetPhotoTiles()
replyContainer.isHidden = true replyContainer.isHidden = true
replyMessageId = nil
fileContainer.isHidden = true fileContainer.isHidden = true
callArrowView.isHidden = true callArrowView.isHidden = true
callBackButton.isHidden = true callBackButton.isHidden = true

View File

@@ -104,6 +104,11 @@ final class NativeMessageListController: UIViewController {
/// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback. /// Dedup for scrollViewDidScroll onScrollToBottomVisibilityChange callback.
private var lastReportedAtBottom: Bool = true 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) // MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>? private var emptyStateHosting: UIHostingController<EmptyChatContent>?
private var emptyStateGuide: UILayoutGuide? private var emptyStateGuide: UILayoutGuide?
@@ -136,6 +141,7 @@ final class NativeMessageListController: UIViewController {
setupCollectionView() setupCollectionView()
setupNativeCellRegistration() setupNativeCellRegistration()
setupDataSource() setupDataSource()
setupFloatingDateHeader()
// Create pure UIKit composer after view hierarchy is ready. // Create pure UIKit composer after view hierarchy is ready.
if shouldSetupComposer { if shouldSetupComposer {
@@ -188,6 +194,9 @@ final class NativeMessageListController: UIViewController {
} }
// ComposerView reports height via delegate (composerHeightDidChange). // ComposerView reports height via delegate (composerHeightDidChange).
// No polling needed pure UIKit, no UIHostingController inflation bug. // 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() { override func viewSafeAreaInsetsDidChange() {
@@ -260,6 +269,7 @@ final class NativeMessageListController: UIViewController {
let replyAtt = message.attachments.first { $0.type == .messages } let replyAtt = message.attachments.first { $0.type == .messages }
var replyName: String? var replyName: String?
var replyText: String? var replyText: String?
var replyMessageId: String?
var forwardSenderName: String? var forwardSenderName: String?
if let att = replyAtt { if let att = replyAtt {
@@ -287,6 +297,7 @@ final class NativeMessageListController: UIViewController {
// Reply quote // Reply quote
replyName = name replyName = name
replyText = first.message.isEmpty ? "Photo" : first.message replyText = first.message.isEmpty ? "Photo" : first.message
replyMessageId = first.message_id
} }
} }
} }
@@ -300,6 +311,7 @@ final class NativeMessageListController: UIViewController {
actions: self.config.actions, actions: self.config.actions,
replyName: replyName, replyName: replyName,
replyText: replyText, replyText: replyText,
replyMessageId: replyMessageId,
forwardSenderName: forwardSenderName forwardSenderName: forwardSenderName
) )
} }
@@ -390,7 +402,7 @@ final class NativeMessageListController: UIViewController {
let container = UIView(frame: .zero) let container = UIView(frame: .zero)
container.translatesAutoresizingMaskIntoConstraints = false container.translatesAutoresizingMaskIntoConstraints = false
container.backgroundColor = .clear container.backgroundColor = .clear
container.clipsToBounds = true container.clipsToBounds = false
container.isUserInteractionEnabled = true container.isUserInteractionEnabled = true
view.addSubview(container) view.addSubview(container)
@@ -417,7 +429,7 @@ final class NativeMessageListController: UIViewController {
// transform matrix during interactive keyboard dismiss. // transform matrix during interactive keyboard dismiss.
let button = UIButton(type: .custom) let button = UIButton(type: .custom)
button.frame = rect button.frame = rect
button.clipsToBounds = true button.clipsToBounds = false
button.alpha = 0 button.alpha = 0
button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0) button.layer.transform = CATransform3DMakeScale(0.2, 0.2, 1.0)
button.layer.allowsEdgeAntialiasing = true 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) // MARK: - Empty State (UIKit-managed, animates with keyboard)
func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) { func updateEmptyState(isEmpty: Bool, info: EmptyChatInfo) {
@@ -957,6 +1173,8 @@ final class NativeMessageListController: UIViewController {
extension NativeMessageListController: UICollectionViewDelegate { extension NativeMessageListController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingDateHeader()
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
let isAtBottom = offsetFromBottom < 50 let isAtBottom = offsetFromBottom < 50
updateScrollToBottomBadge() updateScrollToBottomBadge()

View File

@@ -21,10 +21,8 @@ enum TelegramContextMenuBuilder {
) -> [TelegramContextMenuItem] { ) -> [TelegramContextMenuItem] {
var items: [TelegramContextMenuItem] = [] var items: [TelegramContextMenuItem] = []
let isAvatarOrForwarded = message.attachments.contains(where: { let isAvatar = message.attachments.contains(where: { $0.type == .avatar })
$0.type == .avatar || $0.type == .messages let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatar
})
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
if canReplyForward { if canReplyForward {
items.append(TelegramContextMenuItem( items.append(TelegramContextMenuItem(

View File

@@ -154,7 +154,13 @@ private extension ChatRowView {
if isTyping && !dialog.isSavedMessages { if isTyping && !dialog.isSavedMessages {
return "typing..." 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" return "No messages yet"
} }
if let cached = Self.messageTextCache[dialog.lastMessage] { if let cached = Self.messageTextCache[dialog.lastMessage] {
@@ -170,6 +176,27 @@ private extension ChatRowView {
Self.messageTextCache[dialog.lastMessage] = result Self.messageTextCache[dialog.lastMessage] = result
return 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 // MARK: - Trailing Column

View File

@@ -116,8 +116,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
let shared = UserDefaults(suiteName: "group.com.rosetta.dev") let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
// MARK: Sender identification // MARK: Sender identification
// Server sends `from` = sender public key (personal_message) or group ID (group_message). // Server sends `dialog` = sender public key (personal_message) or group ID (group_message).
let senderKey = userInfo["from"] as? String ?? Self.extractSenderKey(from: userInfo) let senderKey = userInfo["dialog"] as? String
?? Self.extractSenderKey(from: userInfo)
// Resolve sender display name from App Group cache (synced by DialogRepository). // Resolve sender display name from App Group cache (synced by DialogRepository).
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] 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") ?? [] ?? UserDefaults.standard.stringArray(forKey: "muted_chats_keys") ?? []
let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey) let isMuted = !senderKey.isEmpty && mutedKeys.contains(senderKey)
// If server sent visible alert, NSE handles sound+badge. Just sync badge. // If server sent visible alert, NSE handles sound+badge don't double-count.
// If muted, wake app but don't show notification. // If muted, wake app but don't show notification (NSE also suppresses muted).
if hasVisibleAlert || isMuted { 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) completionHandler(.newData)
return return
} }
@@ -276,7 +269,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
userInfo: [AnyHashable: Any], userInfo: [AnyHashable: Any],
completionHandler: @escaping (UIBackgroundFetchResult) -> Void 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 { guard !callerKey.isEmpty else {
completionHandler(.noData) completionHandler(.noData)
return return
@@ -314,7 +308,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
/// this helper is a fallback for other contexts (notification tap, etc.). /// this helper is a fallback for other contexts (notification tap, etc.).
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String { private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
firstNonBlank(userInfo, keys: [ firstNonBlank(userInfo, keys: [
"from", "sender_public_key", "from_public_key", "fromPublicKey", "dialog", "sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey" "public_key", "publicKey"
]) ?? "" ]) ?? ""
} }

View File

@@ -14,9 +14,9 @@ final class NotificationService: UNNotificationServiceExtension {
private static let badgeKey = "app_badge_count" private static let badgeKey = "app_badge_count"
/// Android parity: multiple key names for sender public key extraction. /// 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 = [ private static let senderKeyNames = [
"from", "sender_public_key", "from_public_key", "fromPublicKey", "dialog", "sender_public_key", "from_public_key", "fromPublicKey",
"public_key", "publicKey" "public_key", "publicKey"
] ]
private static let senderNameKeyNames = [ private static let senderNameKeyNames = [
@@ -86,7 +86,7 @@ final class NotificationService: UNNotificationServiceExtension {
content.sound = .default content.sound = .default
content.categoryIdentifier = "call" content.categoryIdentifier = "call"
let callerKey = content.userInfo["from"] as? String let callerKey = content.userInfo["dialog"] as? String
?? Self.extractSenderKey(from: content.userInfo) ?? Self.extractSenderKey(from: content.userInfo)
let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:] let contactNames = shared?.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
let callerName = contactNames[callerKey] let callerName = contactNames[callerKey]
@@ -116,8 +116,8 @@ final class NotificationService: UNNotificationServiceExtension {
// 1. Add sound for vibration server APNs payload has no sound field. // 1. Add sound for vibration server APNs payload has no sound field.
content.sound = .default content.sound = .default
// 2. Extract sender key server sends `from` field. // 2. Extract sender key server sends `dialog` field (was `from`).
let senderKey = content.userInfo["from"] as? String let senderKey = content.userInfo["dialog"] as? String
?? Self.extractSenderKey(from: content.userInfo) ?? Self.extractSenderKey(from: content.userInfo)
// 3. Filter muted chats BEFORE badge increment muted chats must not inflate badge. // 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) ?? Self.firstNonBlank(content.userInfo, keys: Self.senderNameKeyNames)
if let resolvedName, !resolvedName.isEmpty { if let resolvedName, !resolvedName.isEmpty {
updatedInfo["sender_name"] = resolvedName updatedInfo["sender_name"] = resolvedName
if content.title.isEmpty { // Always prefer local name server sends title at push time,
content.title = resolvedName // but user may have a custom contact name in App Group cache.
} content.title = resolvedName
} }
content.userInfo = updatedInfo content.userInfo = updatedInfo